diff --git a/README.md b/README.md index 9737acd..377ac19 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,10 @@ int SuitsToInt(CardSuit suit) => switch { https://www.nuget.org/packages/StaticCS.Async A library for structured concurrency in C#. See [the Async README.md](src/Async/README.md) for more info. + +### StaticCS.CsSig + +An analyzer that pins a project's public API surface using `.cssig` files — ordinary C# member +declarations with no bodies. The analyzer enforces that the project's public API exactly matches the +declared signatures, in both directions (like the Roslyn Public API analyzer, but using real C# +instead of a flat text format). See [the CsSig README.md](src/CsSig/README.md) for more info. diff --git a/src/CsSig/Analyzer/ApiSurface.cs b/src/CsSig/Analyzer/ApiSurface.cs new file mode 100644 index 0000000..5ad5d97 --- /dev/null +++ b/src/CsSig/Analyzer/ApiSurface.cs @@ -0,0 +1,287 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace CsSig; + +/// +/// Determines a compilation's public API surface and reduces each member to a structural +/// signature, so that symbols originating from two different compilations +/// (the project and the synthetic .cssig compilation) can be compared for equivalence. +/// +/// The surface-detection rules and the display format are ported from the Roslyn Public API +/// analyzer (dotnet/roslyn: +/// src/RoslynAnalyzers/PublicApiAnalyzers/Core/Analyzers/DeclarePublicApiAnalyzer*.cs). +/// +internal static class ApiSurface +{ + private const string InstanceConstructorName = ".ctor"; + + /// + /// 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); + + /// + /// Builds a map from the structural signature of every externally visible member declared in + /// to its source location and a human-readable display string. + /// + public static Dictionary Collect(IAssemblySymbol assembly) + { + var map = new Dictionary(); + + foreach (var type in AllNamedTypes(assembly.GlobalNamespace)) + { + if (!IsTrackedApi(type)) + { + continue; + } + + Add(map, type); + + foreach (var member in GetApiMembers(type)) + { + Add(map, member); + } + } + + return map; + } + + private static void Add(Dictionary map, ISymbol symbol) + { + var member = ApiMember.From(symbol); + if (map.ContainsKey(member.Identity)) + { + return; + } + + map.Add(member.Identity, new ApiEntry(member, GetLocation(symbol), GetDisplay(symbol))); + } + + private static Location GetLocation(ISymbol symbol) + { + var location = symbol.Locations.FirstOrDefault(static l => l.IsInSource); + if (location is null && symbol.ContainingType is { } containingType) + { + location = containingType.Locations.FirstOrDefault(static l => l.IsInSource); + } + + return location ?? Location.None; + } + + /// Yields the tracked members of , including implicit + /// constructors and implicit record members, mirroring the Public API analyzer. + private static IEnumerable GetApiMembers(INamedTypeSymbol type) + { + foreach (var member in type.GetMembers()) + { + // Nested types are visited independently by AllNamedTypes. + if (member is INamedTypeSymbol) + { + continue; + } + + if (member.IsImplicitlyDeclared) + { + continue; + } + + if (IsTrackedApi(member)) + { + yield return member; + } + } + + // Implicitly declared (parameterless) constructor. + IMethodSymbol? implicitConstructor = null; + if (type is { TypeKind: TypeKind.Class, InstanceConstructors.Length: 1 } or { TypeKind: TypeKind.Struct }) + { + implicitConstructor = type.InstanceConstructors.FirstOrDefault(static c => c.IsImplicitlyDeclared); + if (implicitConstructor is not null && IsTrackedApi(implicitConstructor)) + { + yield return implicitConstructor; + } + } + + // Implicitly declared members of a record (Equals, GetHashCode, Deconstruct, copy ctor, + // positional property accessors, ...). + foreach (var member in type.GetMembers()) + { + if (SymbolEqualityComparer.Default.Equals(member, implicitConstructor)) + { + continue; + } + + 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 }) + { + yield return method; + } + } + } + } + + private static IEnumerable AllNamedTypes(INamespaceSymbol root) + { + var stack = new Stack(); + stack.Push(root); + + while (stack.Count > 0) + { + var current = stack.Pop(); + foreach (var member in current.GetMembers()) + { + switch (member) + { + case INamespaceSymbol ns: + stack.Push(ns); + break; + case INamedTypeSymbol type: + yield return type; + stack.Push(type); + break; + } + } + } + } + + /// + /// Whether a symbol is part of the externally visible (public) API surface. + /// Ported from the Public API analyzer's IsTrackedAPI/IsTrackedApiCore. + /// + public static bool IsTrackedApi(ISymbol symbol) + { + if (symbol is IMethodSymbol methodSymbol) + { + // Event accessors are encoded via the event symbol itself. + if (methodSymbol.MethodKind is MethodKind.EventAdd or MethodKind.EventRemove) + { + return false; + } + + // Enum constructors are not user-visible API. + 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 }) + { + return false; + } + } + + // Properties are not tracked directly; their get/set accessors (IMethodSymbols) are. + if (symbol is IPropertySymbol) + { + return false; + } + + if (symbol.GetResultantVisibility() != SymbolVisibility.Public) + { + return false; + } + + for (ISymbol? current = symbol; current is not null; current = current.ContainingType) + { + switch (current.DeclaredAccessibility) + { + case Accessibility.Protected: + 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)) + { + return false; + } + + break; + } + } + + return true; + } + + 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, + }); + } + + /// + /// Produces a human-readable signature string for diagnostic messages only. Ported from the + /// Public API analyzer's getApiString (without the nullability/oblivious variants). + /// + private static string GetDisplay(ISymbol symbol) + { + var signature = symbol.ToDisplayString(s_displayFormat); + + ITypeSymbol? memberType = symbol switch + { + IMethodSymbol method => method.ReturnType, + IPropertySymbol property => property.Type, + IEventSymbol @event => @event.Type, + IFieldSymbol field => field.Type, + _ => null, + }; + + if (memberType is not null) + { + signature = signature + " -> " + memberType.ToDisplayString(s_displayFormat); + } + + return signature; + } +} + +/// A located, human-readable record of a single API member. +internal readonly struct ApiEntry +{ + public ApiEntry(ApiMember member, Location location, string display) + { + Member = member; + Location = location; + Display = display; + } + + /// The structural model, used to compare source/binary equivalence. + public ApiMember Member { get; } + + public Location Location { get; } + + public string Display { get; } +} diff --git a/src/CsSig/Analyzer/CsSigAnalyzer.cs b/src/CsSig/Analyzer/CsSigAnalyzer.cs new file mode 100644 index 0000000..cfc6d60 --- /dev/null +++ b/src/CsSig/Analyzer/CsSigAnalyzer.cs @@ -0,0 +1,234 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace CsSig; + +/// +/// Checks that a project's public API surface exactly matches the C# signatures declared in its +/// .cssig additional files. The approach mirrors the Roslyn Public API analyzer, but the +/// source of truth is real (body-less) C# rather than a flat text format: the .cssig files +/// are parsed into a synthetic compilation, symbols are produced from both it and the project, and +/// the two sets are compared for equivalence via structural signatures. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class CsSigAnalyzer : DiagnosticAnalyzer +{ + internal const string Extension = ".cssig"; + + private static readonly DiagnosticDescriptor s_missingFromProject = new( + id: DiagId.MissingFromProject.ToIdString(), + title: "Signature is missing from the project", + 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); + + private static readonly DiagnosticDescriptor s_missingFromSignature = new( + id: DiagId.MissingFromSignature.ToIdString(), + title: "Public API is missing from the .cssig file", + 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); + + private static readonly DiagnosticDescriptor s_signatureFileError = new( + id: DiagId.SignatureFileError.ToIdString(), + title: "Invalid .cssig file", + messageFormat: "The .cssig file could not be parsed: {0}", + category: "CsSig", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor s_signatureMismatch = new( + id: DiagId.SignatureMismatch.ToIdString(), + title: "Signature does not match the project", + 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); + + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create( + s_missingFromProject, s_missingFromSignature, s_signatureFileError, s_signatureMismatch, CsSigRecognizer.Rule); + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + 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)) + .ToImmutableArray(); + + // Nothing to enforce unless the project declares signatures. When one or more .cssig + // files are present they are all included by default and define the entire public surface. + if (sigFiles.IsEmpty) + { + return; + } + + var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + ?? CSharpParseOptions.Default; + + var sigTrees = new List(sigFiles.Length); + var hadParseError = false; + foreach (var file in sigFiles) + { + var text = file.GetText(context.CancellationToken); + if (text is null) + { + continue; + } + + 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())); + } + } + + // Recognize the .cssig grammar: reject any construct outside the signature sublanguage + // (the 'partial' modifier, member bodies, ...). This is purely syntactic and produces + // no model; the comparison model is derived from symbols below. + foreach (var diagnostic in CsSigRecognizer.Recognize(tree, context.CancellationToken)) + { + context.ReportDiagnostic(diagnostic); + } + + sigTrees.Add(tree); + } + + // If a .cssig file is malformed we can't reliably diff; surface the parse errors only. + if (hadParseError || sigTrees.Count == 0) + { + return; + } + + var sigCompilation = CSharpCompilation.Create( + "__cssig__", + sigTrees, + compilation.References, + compilation.Options as CSharpCompilationOptions); + + var declared = ApiSurface.Collect(sigCompilation.Assembly); + var actual = ApiSurface.Collect(compilation.Assembly); + + var mode = ReadEquivalence(context.Options); + + foreach (var pair in declared) + { + context.CancellationToken.ThrowIfCancellationRequested(); + + if (!actual.TryGetValue(pair.Key, out var actualEntry)) + { + // 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))); + 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 + && !Equals(pair.Value.Member.Source, actualEntry.Member.Source); + 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)))); + } + } + + // Part of the project's public API but not declared in any .cssig file. + foreach (var pair in actual) + { + context.CancellationToken.ThrowIfCancellationRequested(); + if (!declared.ContainsKey(pair.Key)) + { + context.ReportDiagnostic(Diagnostic.Create( + s_missingFromSignature, + pair.Value.Location, + pair.Value.Display, + Describe(mode))); + } + } + } + + /// + /// Reads the CsSigEquivalence MSBuild property (Source / Binary / Both, default Both) + /// that selects which equivalence relation(s) the analyzer enforces. + /// + private static Equivalence ReadEquivalence(AnalyzerOptions options) + { + if (options.AnalyzerConfigOptionsProvider.GlobalOptions + .TryGetValue("build_property.CsSigEquivalence", out var raw) + && !string.IsNullOrWhiteSpace(raw)) + { + switch (raw.Trim().ToLowerInvariant()) + { + case "source": + return Equivalence.Source; + case "binary": + return Equivalence.Binary; + case "both": + case "strict": + return Equivalence.Both; + } + } + + return Equivalence.Both; + } + + 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. +[System.Flags] +internal enum Equivalence +{ + None = 0, + + /// Members must be usable identically from source (names, optional/params, …). + Source = 1, + + /// Members must be binary-compatible (const values baked into consumers, …). + Binary = 2, + + /// Both source and binary equivalence are enforced. + Both = Source | Binary, +} diff --git a/src/CsSig/Analyzer/CsSigLocation.cs b/src/CsSig/Analyzer/CsSigLocation.cs new file mode 100644 index 0000000..e1024af --- /dev/null +++ b/src/CsSig/Analyzer/CsSigLocation.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; + +namespace CsSig; + +/// +/// Helpers for reporting diagnostics against .cssig additional files. +/// +internal static class CsSigLocation +{ + /// + /// Converts a location that lives in a synthetic .cssig syntax tree (which is not part + /// of the analyzed compilation) into an external-file location that can be safely reported. + /// + public static Location ToExternal(Location location, string fallbackPath) + { + if (!location.IsInSource) + { + return Location.None; + } + + var path = location.SourceTree?.FilePath; + if (string.IsNullOrEmpty(path)) + { + path = fallbackPath; + } + + return Location.Create(path!, location.SourceSpan, location.GetLineSpan().Span); + } +} diff --git a/src/CsSig/Analyzer/CsSigRecognizer.cs b/src/CsSig/Analyzer/CsSigRecognizer.cs new file mode 100644 index 0000000..0aff8bf --- /dev/null +++ b/src/CsSig/Analyzer/CsSigRecognizer.cs @@ -0,0 +1,275 @@ +using System.Collections.Generic; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CsSig; + +/// +/// Recognizes the .cssig grammar, a restricted sublanguage of C#, reporting a diagnostic +/// (, CSSIG004) for every construct outside it. +/// +/// +/// +/// C#'s own lexer/parser does the text-to-tree work (producing a ); this +/// pass operates one level up, parsing that tree against the .cssig grammar and rejecting +/// anything outside it. Its sole purpose is recognition: it produces ordinary analyzer diagnostics +/// and no model. The structural model used for comparison is derived from symbols (see +/// and ), so both the project and signature sides +/// go through identical logic. +/// +/// +/// The grammar is deliberately derived from what the comparison actually observes: a .cssig +/// file may only express things that affect a signature. The comparison looks at accessibility, +/// static, virtuality (virtual/abstract/override/sealed), +/// type-level abstract/sealed, field readonly and const values, return +/// and parameter types, and parameter ref/params kinds. Every other modifier (new, +/// async, volatile, extern, unsafe, required, partial, …), +/// member bodies, and non-const field initializers are invisible to it and are therefore +/// rejected — declaring them would let a .cssig claim something the analyzer silently ignores. +/// +/// +internal static class CsSigRecognizer +{ + /// Diagnostic reported for any construct outside the .cssig grammar. + public static readonly DiagnosticDescriptor Rule = new( + id: DiagId.DisallowedSignatureSyntax.ToIdString(), + title: "Disallowed construct in .cssig file", + messageFormat: "{0}", + category: "CsSig", + defaultSeverity: DiagnosticSeverity.Error, + 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) + { + var walker = new Walker(tree.FilePath); + walker.Visit(tree.GetRoot(cancellationToken)); + return walker.Diagnostics; + } + + private sealed class Walker : CSharpSyntaxWalker + { + private readonly string _path; + + public Walker(string path) => _path = path; + + public List Diagnostics { get; } = new(); + + 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; + + /// + /// Modifiers allowed on a virtualizable member (method, property, indexer, event): the + /// comparison captures all of these as part of the member's virtuality. + /// + private static readonly SyntaxKind[] s_memberModifiers = + { + SyntaxKind.StaticKeyword, + SyntaxKind.VirtualKeyword, + SyntaxKind.AbstractKeyword, + SyntaxKind.OverrideKeyword, + SyntaxKind.SealedKeyword, + }; + + // Members that can be 'readonly' on a struct (the modifier makes the member readonly, + // turning `this` into an `in` parameter — observable in both source and binary). + private static readonly SyntaxKind[] s_readonlyMemberModifiers = + { + SyntaxKind.StaticKeyword, + SyntaxKind.VirtualKeyword, + SyntaxKind.AbstractKeyword, + SyntaxKind.OverrideKeyword, + SyntaxKind.SealedKeyword, + SyntaxKind.ReadOnlyKeyword, + }; + + /// + /// Rejects every modifier that is not accessibility (always allowed, since it determines + /// surface membership) or one of the modifiers known to affect + /// the signature for this declaration kind. + /// + private void CheckModifiers(SyntaxTokenList modifiers, params SyntaxKind[] allowed) + { + foreach (var modifier in modifiers) + { + var kind = modifier.Kind(); + if (IsAccessibility(kind) || System.Array.IndexOf(allowed, kind) >= 0) + { + continue; + } + + Report( + modifier.GetLocation(), + $"The '{modifier.ValueText}' modifier does not affect the signature and is not allowed in a .cssig file"); + } + } + + private void RejectBody(BlockSyntax? body, ArrowExpressionClauseSyntax? expressionBody) + { + if (body is not null) + { + Report( + body.GetLocation(), + "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"); + } + } + + 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); + base.VisitClassDeclaration(node); + } + + public override void VisitStructDeclaration(StructDeclarationSyntax node) + { + // Structs are always sealed and never static; only accessibility is observable. + CheckModifiers(node.Modifiers); + base.VisitStructDeclaration(node); + } + + public override void VisitInterfaceDeclaration(InterfaceDeclarationSyntax node) + { + CheckModifiers(node.Modifiers); + base.VisitInterfaceDeclaration(node); + } + + public override void VisitRecordDeclaration(RecordDeclarationSyntax node) + { + if (node.ClassOrStructKeyword.IsKind(SyntaxKind.StructKeyword)) + { + CheckModifiers(node.Modifiers); + } + else + { + CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword, SyntaxKind.AbstractKeyword, SyntaxKind.SealedKeyword); + } + + base.VisitRecordDeclaration(node); + } + + public override void VisitEnumDeclaration(EnumDeclarationSyntax node) + { + CheckModifiers(node.Modifiers); + base.VisitEnumDeclaration(node); + } + + public override void VisitDelegateDeclaration(DelegateDeclarationSyntax node) + { + CheckModifiers(node.Modifiers); + base.VisitDelegateDeclaration(node); + } + + public override void VisitMethodDeclaration(MethodDeclarationSyntax node) + { + // 'readonly' is allowed: on a struct instance method it marks the method readonly, + // making `this` an `in` parameter (observable in both source and binary). + CheckModifiers(node.Modifiers, s_readonlyMemberModifiers); + RejectBody(node.Body, node.ExpressionBody); + base.VisitMethodDeclaration(node); + } + + public override void VisitOperatorDeclaration(OperatorDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword); + RejectBody(node.Body, node.ExpressionBody); + base.VisitOperatorDeclaration(node); + } + + public override void VisitConversionOperatorDeclaration(ConversionOperatorDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, SyntaxKind.StaticKeyword); + RejectBody(node.Body, node.ExpressionBody); + base.VisitConversionOperatorDeclaration(node); + } + + public override void VisitConstructorDeclaration(ConstructorDeclarationSyntax node) + { + // Only constructor accessibility is observable (via CanTypeBeExtended). + CheckModifiers(node.Modifiers); + RejectBody(node.Body, node.ExpressionBody); + base.VisitConstructorDeclaration(node); + } + + public override void VisitDestructorDeclaration(DestructorDeclarationSyntax node) + { + RejectBody(node.Body, node.ExpressionBody); + base.VisitDestructorDeclaration(node); + } + + public override void VisitPropertyDeclaration(PropertyDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, s_readonlyMemberModifiers); + RejectBody(body: null, node.ExpressionBody); + base.VisitPropertyDeclaration(node); + } + + public override void VisitIndexerDeclaration(IndexerDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, s_readonlyMemberModifiers); + RejectBody(body: null, node.ExpressionBody); + base.VisitIndexerDeclaration(node); + } + + public override void VisitEventDeclaration(EventDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, s_memberModifiers); + base.VisitEventDeclaration(node); + } + + public override void VisitEventFieldDeclaration(EventFieldDeclarationSyntax node) + { + CheckModifiers(node.Modifiers, s_memberModifiers); + base.VisitEventFieldDeclaration(node); + } + + 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); + + // A field's value is only part of the signature when it is 'const'; any other + // initializer is invisible to the comparison. + if (!node.Modifiers.Any(SyntaxKind.ConstKeyword)) + { + foreach (var variable in node.Declaration.Variables) + { + if (variable.Initializer is { } initializer) + { + 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"); + } + } + } + + base.VisitFieldDeclaration(node); + } + + public override void VisitAccessorDeclaration(AccessorDeclarationSyntax node) + { + // Accessor accessibility (e.g. 'private set') determines whether the accessor surfaces. + CheckModifiers(node.Modifiers); + RejectBody(node.Body, node.ExpressionBody); + base.VisitAccessorDeclaration(node); + } + } +} diff --git a/src/CsSig/Analyzer/CsSigWriter.cs b/src/CsSig/Analyzer/CsSigWriter.cs new file mode 100644 index 0000000..1df6e48 --- /dev/null +++ b/src/CsSig/Analyzer/CsSigWriter.cs @@ -0,0 +1,337 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Microsoft.CodeAnalysis; +using StaticCs; + +namespace CsSig; + +/// +/// Generates the text of a .cssig file from a compilation's public API surface: the inverse +/// of . Every externally visible member is emitted as a body-less C# +/// declaration, grouped by namespace and nesting, so that feeding the result back through +/// produces no diagnostics. +/// +/// +/// Only explicitly declared members are emitted; implicit members (default constructors, record +/// members, …) are intentionally omitted because the same declaration causes the compiler to +/// synthesize identical members on both the project and the generated signature. +/// +public static class CsSigWriter +{ + // Full signature of a non-type member, body-less. Types are fully qualified so the generated + // file needs no using directives. SymbolDisplay omits non-signature modifiers such as `unsafe` + // and `async`; the few it still emits (`volatile`, `required`) are scrubbed afterwards. + private static readonly SymbolDisplayFormat s_memberFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.OmittedAsContaining, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + 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); + + // 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); + + /// Generates the .cssig text for 's public API. + public static string Write(Compilation compilation) => Write(compilation.Assembly); + + /// Generates the .cssig text for 's public API. + public static string Write(IAssemblySymbol assembly) => Write(assembly, topLevelKeys: null); + + /// + /// Generates the .cssig text for the subset of 's public API + /// whose top-level types are named in (see + /// ). When is + /// the entire surface is written. The code fix uses this to (re)generate a single signature + /// file that owns a particular set of top-level types. + /// + public static string Write(IAssemblySymbol assembly, ISet? topLevelKeys) + { + var byNamespace = new SortedDictionary>(StringComparer.Ordinal); + foreach (var type in TopLevelTypes(assembly.GlobalNamespace)) + { + if (!ApiSurface.IsTrackedApi(type)) + { + continue; + } + + if (topLevelKeys is not null && !topLevelKeys.Contains(TopLevelKey(type))) + { + continue; + } + + var ns = type.ContainingNamespace is { IsGlobalNamespace: false } n ? n.ToDisplayString() : string.Empty; + if (!byNamespace.TryGetValue(ns, out var list)) + { + byNamespace[ns] = list = new List(); + } + + list.Add(type); + } + + var builder = new IndentingBuilder(); + var first = true; + foreach (var entry in byNamespace) + { + if (!first) + { + builder.AppendLine(""); + } + + first = false; + + if (entry.Key.Length == 0) + { + foreach (var type in Sorted(entry.Value)) + { + WriteType(builder, type); + } + } + else + { + builder.AppendLine($"namespace {entry.Key}"); + builder.AppendLine("{"); + builder.Indent(); + foreach (var type in Sorted(entry.Value)) + { + WriteType(builder, type); + } + + builder.Dedent(); + builder.AppendLine("}"); + } + } + + return builder.ToString(); + } + + private static void WriteType(IndentingBuilder builder, INamedTypeSymbol type) + { + if (type.TypeKind == TypeKind.Delegate) + { + builder.AppendLine(DelegateDeclaration(type) + ";"); + return; + } + + builder.AppendLine(TypeHeader(type)); + builder.AppendLine("{"); + builder.Indent(); + + if (type.TypeKind == TypeKind.Enum) + { + foreach (var field in type.GetMembers().OfType().Where(f => f.HasConstantValue)) + { + builder.AppendLine(FormatMember(field)); + } + } + else + { + foreach (var member in Sorted(VisibleMembers(type))) + { + builder.AppendLine(FormatMember(member)); + } + + foreach (var nested in Sorted(type.GetMembers().OfType().Where(ApiSurface.IsTrackedApi))) + { + WriteType(builder, nested); + } + } + + builder.Dedent(); + builder.AppendLine("}"); + } + + /// 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,. + private static string FormatMember(ISymbol member) + { + if (member is IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum } enumField) + { + return $"{enumField.Name} = {FormatConstant(enumField.ConstantValue)},"; + } + + var text = member.ToDisplayString(s_memberFormat) + .Replace("volatile ", string.Empty) + .Replace("required ", string.Empty); + + // ShowReadWriteDescriptor already renders the `{ get; set; }` body for properties/indexers, + // so only non-property members need a terminating semicolon. + return member is IPropertySymbol ? text : text + ";"; + } + + private static string TypeHeader(INamedTypeSymbol type) + { + var parts = new List { Accessibility(type.DeclaredAccessibility) }; + + if (type.IsStatic) + { + parts.Add("static"); + } + else if (type.TypeKind == TypeKind.Class) + { + if (type.IsAbstract) + { + parts.Add("abstract"); + } + + if (type.IsSealed) + { + parts.Add("sealed"); + } + } + + 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); + } + + 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); + return $"{Accessibility(type.DeclaredAccessibility)} delegate {@return} " + + $"{type.Name}{TypeParameterList(type)}({FormatParameters(invoke.Parameters)})"; + } + + 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 + { + 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 + /// their property/event) so the set matches what compares. + private static IEnumerable VisibleMembers(INamedTypeSymbol type) + { + foreach (var member in type.GetMembers()) + { + if (member is INamedTypeSymbol || member.IsImplicitlyDeclared) + { + continue; + } + + if (member is IMethodSymbol + { + MethodKind: MethodKind.PropertyGet or MethodKind.PropertySet + or MethodKind.EventAdd or MethodKind.EventRemove, + }) + { + continue; + } + + if (IsVisible(member)) + { + yield return 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) + { + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0) + { + foreach (var member in stack.Pop().GetMembers()) + { + switch (member) + { + case INamespaceSymbol ns: + stack.Push(ns); + break; + case INamedTypeSymbol type: + yield return type; + break; + } + } + } + } + + 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 + /// arity (e.g. N.Outer`1). Used to decide which signature file owns a type. The same + /// string can be reconstructed from a .cssig type declaration's syntax, so the code fix + /// can match a declared type back to its project symbol. + /// + public static string TopLevelKey(INamedTypeSymbol type) + { + 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"; +} diff --git a/src/CsSig/Analyzer/DiagIds.cs b/src/CsSig/Analyzer/DiagIds.cs new file mode 100644 index 0000000..6046455 --- /dev/null +++ b/src/CsSig/Analyzer/DiagIds.cs @@ -0,0 +1,29 @@ +namespace CsSig; + +/// +/// Diagnostic ids reported by the .cssig analyzer. +/// +public enum DiagId +{ + /// A signature declared in a .cssig file is not present in the project. + MissingFromProject = 1, + + /// A public API member in the project is not declared in any .cssig file. + MissingFromSignature = 2, + + /// A .cssig file could not be parsed. + SignatureFileError = 3, + + /// A .cssig file uses a construct that is not allowed in a signature file. + DisallowedSignatureSyntax = 4, + + /// A member exists on both sides but its signature is not equivalent. + SignatureMismatch = 5, +} + +public static class DiagUtils +{ + private const string DiagPrefix = "CSSIG"; + + public static string ToIdString(this DiagId id) => $"{DiagPrefix}{(int)id:D3}"; +} diff --git a/src/CsSig/Analyzer/EqArray.cs b/src/CsSig/Analyzer/EqArray.cs new file mode 100644 index 0000000..1a2ea0d --- /dev/null +++ b/src/CsSig/Analyzer/EqArray.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace CsSig; + +/// +/// A small immutable array wrapper that provides structural (element-wise) equality, so it can be +/// used as a member of record types and still participate correctly in value equality. +/// +internal readonly struct EqArray(ImmutableArray _array) : IEquatable> + where T : IEquatable +{ + public ImmutableArray Array => _array.IsDefault ? ImmutableArray.Empty : _array; + + public int Length => Array.Length; + + public T this[int index] => Array[index]; + + public ImmutableArray.Enumerator GetEnumerator() => Array.GetEnumerator(); + + public bool Equals(EqArray other) + { + var left = Array; + var right = other.Array; + if (left.Length != right.Length) + { + return false; + } + + for (var i = 0; i < left.Length; i++) + { + if (!EqualityComparer.Default.Equals(left[i], right[i])) + { + return false; + } + } + + return true; + } + + public override bool Equals(object? obj) => obj is EqArray other && Equals(other); + + public override int GetHashCode() + { + var hash = 17; + foreach (var item in Array) + { + hash = unchecked((hash * 31) + (item?.GetHashCode() ?? 0)); + } + + return hash; + } + + public static EqArray From(IEnumerable items) => new(items.ToImmutableArray()); +} diff --git a/src/CsSig/Analyzer/GRAMMAR.md b/src/CsSig/Analyzer/GRAMMAR.md new file mode 100644 index 0000000..c1aebf8 --- /dev/null +++ b/src/CsSig/Analyzer/GRAMMAR.md @@ -0,0 +1,148 @@ +# The `.cssig` grammar + +A `.cssig` file describes a project's externally visible API surface using ordinary C# member +declarations **with no bodies**. This document specifies exactly which C# constructs are legal in a +`.cssig` file. + +The grammar is a **restricted sublanguage of C#**. A `.cssig` file is first parsed by the C# +parser (so its lexical and syntactic structure is exactly C#'s), and is then validated by the +[recognizer](CsSigRecognizer.cs) against the additional rules below. Anything the C# parser rejects +is reported as **`CSSIG003`**; anything the recognizer rejects is reported as **`CSSIG004`**. + +## Guiding principle + +> A `.cssig` file may only express things that affect a signature. + +The set of legal constructs is **derived from what the comparison observes** (see +[`SignatureModel.cs`](SignatureModel.cs) and [`ApiSurface.cs`](ApiSurface.cs)). If a construct is +invisible to the comparison, declaring it would let a `.cssig` silently claim something the analyzer +ignores, so the recognizer rejects it. When a new distinction should matter, it is added to *both* +the comparison and this grammar together. + +## Overall shape + +A `.cssig` file is a C# compilation unit: + +```ebnf +compilation-unit = { using-directive } ( file-namespace | { namespace | type-declaration } ) ; +file-namespace = "namespace" qualified-name ";" { using-directive } { type-declaration } ; +namespace = "namespace" qualified-name "{" { using-directive } { type-declaration } "}" ; +``` + +`using` directives and namespace declarations are unrestricted — they exist to bring names into +scope and to place declarations, neither of which is a signature claim. + +## Type declarations + +Legal type declarations are `class`, `struct`, `interface`, `record` (class or struct), `enum`, and +`delegate`. Each may carry only the modifiers that affect the surface: + +| Declaration | Allowed modifiers | Rationale | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------- | +| `class`, `record class`| accessibility, `static`, `abstract`, `sealed` | packed into `CommonTypeAspects.Flags`; `abstract`/`sealed` affect instantiability, virtual dispatch, and whether protected members surface (`CanTypeBeExtended`) | +| `struct`, `record struct`, `interface`, `enum`, `delegate` | accessibility | structs are always sealed/never static; the rest have no observable type modifier | + +"accessibility" means any of `public`, `private`, `protected`, `internal` (including the +combinations `protected internal` and `private protected`). + +## Members + +| Member | Allowed modifiers | Body? | Notes | +| ------------------------------------------ | -------------------------------- | ---------------- | --------------------------------------------------------------- | +| method | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed`, `readonly` | none | declared as `T M(params);`; virtuality is part of the signature; `readonly` allowed on struct methods | +| operator, conversion operator | accessibility, `static` | none | | +| constructor | accessibility | none | constructor accessibility feeds `CanTypeBeExtended` | +| destructor | (none) | none | | +| property, indexer | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed`, `readonly` | no expression body | accessors are written as `{ get; set; }` (see below); `readonly` allowed on struct members | +| accessor (`get` / `set` / `init`) | accessibility | none | `private set` etc. controls whether the accessor surfaces | +| event (field-form or with accessors) | accessibility, `static`, `virtual`, `abstract`, `override`, `sealed` | n/a | typically `event T E;` | +| field | accessibility, `static`, `const`, `readonly` | n/a | initializer allowed **only** with `const` (see below) | +| enum member | (none) | n/a | an explicit `= value` is allowed and is part of the signature | + +`virtual` / `abstract` / `override` / `sealed` on a member are captured as its *virtuality* and +affect both equivalences (see below). Interface members are implicitly `abstract`, so the modifier +need not be written there. + +### No bodies + +Member bodies are not part of a signature, so they are prohibited everywhere: + +- block bodies (`{ … }`) on methods, operators, constructors, destructors, and accessors; +- expression bodies (`=> …`) on methods, operators, properties, indexers, and accessors. + +A body-less method such as `int M();` is a *semantic* error in plain C# (CS0501); the analyzer +parses it successfully and intentionally ignores that semantic error — only the signature matters. + +### Field initializers + +A field's value is part of the signature **only** when the field is `const` (the comparison +captures the constant value). A `const` field therefore requires its initializer: + +```csharp +public const int Limit = 100; // legal: the value is part of the signature +``` + +An initializer on any non-`const` field is invisible to the comparison and is rejected: + +```csharp +public static int Count = 0; // CSSIG004: the initializer is ignored — drop it +public static int Count; // legal +``` + +### Unsafe types + +The `unsafe` modifier has no signature impact, so it is rejected like any other non-signature +modifier. Pointer (`int*`) and function-pointer (`delegate*<…>`) types, however, are part of the +signature and are compared structurally. Write such members **without** `unsafe`: + +```csharp +public unsafe delegate* Callback; // CSSIG004: drop 'unsafe' +public delegate* Callback; // legal +``` + +Omitting `unsafe` makes the declaration a *semantic* error in plain C# (CS0214), exactly as a +body-less `int M();` is (CS0501); the analyzer parses it successfully and intentionally ignores +that semantic error — only the signature matters. + +## Currently accepted but not part of the comparison + +The following constructs are **syntactically accepted** today (the recognizer does not reject them), +but the comparison does **not** currently observe them, so they have no effect. They are candidates +for either rejection or — more likely — being folded into the comparison later: + +- generic constraints (`where T : …`); +- base types and implemented interfaces (`class C : IFoo`); +- generic type-parameter variance (`in` / `out`); +- `ref` / `ref readonly` returns; +- the `scoped` parameter modifier; +- attributes. + +Until that is decided, do not rely on any of these to change what the analyzer enforces. + +## Source vs. binary equivalence + +The comparison enforces one or both of two equivalence relations, selected by the +`CsSigEquivalence` MSBuild property (`Source`, `Binary`, or `Both` — the default): + +- **Common aspects** (compared in *both*): kind, name, arity, parameter types and ref kinds, return + / field / event type, `static`, virtuality, type-level `abstract` / `sealed`, field `readonly`, + struct-member `readonly`, and const-ness. A difference here breaks both equivalences. +- **Source-only**: parameter *names*, `params`, extension `this`, whether a parameter is optional, + `in` vs `ref readonly`, and nullable reference type annotations (`string?`). These change source + call sites but not the binary calling convention. +- **Binary-only**: the `const` *value*, which is baked into already-compiled consumers (a source + recompile picks up a new value). + +Because the grammar only admits constructs the comparison observes, every legal modifier above feeds +one of these buckets. + +## Diagnostics summary + +| Id | Meaning | +| --------- | ----------------------------------------------------------------------- | +| `CSSIG003`| the file is not valid C# (a parse error) | +| `CSSIG004`| the file uses a construct outside this grammar | + +(`CSSIG001` / `CSSIG002` / `CSSIG005` are reported by the *comparison*, not the grammar: a declared +signature missing from the project, a public member missing from the signature files, or a member +present on both sides whose signature is not equivalent. Each names the equivalence it breaks.) diff --git a/src/CsSig/Analyzer/IsExternalInit.cs b/src/CsSig/Analyzer/IsExternalInit.cs new file mode 100644 index 0000000..476a949 --- /dev/null +++ b/src/CsSig/Analyzer/IsExternalInit.cs @@ -0,0 +1,8 @@ +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 + { + } +} diff --git a/src/CsSig/Analyzer/README.md b/src/CsSig/Analyzer/README.md new file mode 100644 index 0000000..7680188 --- /dev/null +++ b/src/CsSig/Analyzer/README.md @@ -0,0 +1,136 @@ +# CsSig — C# signature files (`.cssig`) + +`StaticCS.CsSig` is a Roslyn analyzer, in the spirit of the +[Public API analyzer](https://github.com/dotnet/roslyn/tree/main/src/RoslynAnalyzers/PublicApiAnalyzers), +that lets you pin a project's **public API surface** using ordinary C#. + +Instead of a flat text format, the expected surface is described in a `.cssig` file containing +real C# member declarations **with no bodies**. The analyzer verifies that the project's public +API surface *exactly matches* the declared signatures — in both directions. + +## Example + +`Api.cssig`: + +```csharp +namespace MyLibrary; + +public class Greeter +{ + public Greeter(string name); + public string Greet(); + public string Name { get; } +} +``` + +If the project's public API drifts from this file, you get a build error: + +- `CSSIG001` — a signature is declared in a `.cssig` file but is **missing from the project**. +- `CSSIG002` — a public member exists in the project but is **not declared** in any `.cssig` file. +- `CSSIG003` — a `.cssig` file could not be parsed. +- `CSSIG004` — a `.cssig` file uses a construct outside the signature grammar. The grammar is derived from what the comparison observes, so a `.cssig` can only express things that affect a signature. This rejects modifiers that don't change a signature (`new`, `async`, `volatile`, `extern`, `unsafe`, `required`, `partial`, …), member bodies, and non-`const` field initializers. The modifiers that *are* allowed are accessibility, `static`, `abstract`/`sealed` (types), member virtuality (`virtual`/`abstract`/`override`/`sealed`), `const`/`readonly` (fields), `readonly` (struct methods/properties/indexers), and parameter `ref`/`in`/`out`/`params`/`this`. See [`GRAMMAR.md`](GRAMMAR.md) for the full specification. +- `CSSIG005` — a member is present on both sides but its signature is **not equivalent** (e.g. a changed return type, virtuality, parameter name, or `const` value). The message names which equivalence is broken. + +## Source vs. binary equivalence + +The analyzer enforces one or both of two equivalence relations, chosen with the `CsSigEquivalence` +MSBuild property — `Source`, `Binary`, or `Both` (the default): + +```xml + + Both + +``` + +- **Common** aspects break *both*: types, `static`, virtuality, type-level `abstract`/`sealed`, + field `readonly`/const-ness, struct-member `readonly` (it makes `this` an `in` parameter), and + return/field/event/parameter types. +- **Source-only** aspects break source equivalence only: parameter *names*, `params`, extension + `this`, whether a parameter is optional, `in` vs `ref readonly`, and nullable reference type + annotations (`string?`) — they change source call sites but not the binary calling convention. +- **Binary-only** aspects break binary equivalence only: a `const` *value*, which is baked into + already-compiled consumers (a source recompile picks up a new value). + +Every `CSSIG001`/`CSSIG002`/`CSSIG005` message states which equivalence(s) it breaks. + +## How it works + +The analyzer parses every `.cssig` additional file into a *synthetic* compilation (using the +project's own references and compilation options), then produces symbols from both that synthetic +compilation and the project. Each externally-visible member is reduced to a structural model +(`ApiMember` — a `MemberIdentity` pairing key plus two projections, `SourceMember` and +`BinaryMember`, whose record equality *is* source/binary equivalence; types are captured as a +recursive structural `TypeRef`). Members are paired by identity, and each pair's active projections +are compared. Bodyless methods (`int M();`) produce a semantic "missing body" +diagnostic in the synthetic compilation, which is intentionally ignored — only the signature +matters. + +Before comparison, each `.cssig` file is run through a **recognizer** pass that validates it +against the `.cssig` grammar — a restricted sublanguage of C#. C#'s own parser does the +text-to-tree work; the recognizer parses that tree against the signature grammar and rejects +anything outside it as `CSSIG004`. It is purely syntactic and produces no model: the comparison +model is always derived from symbols, so the project and signature sides go through identical logic. + +The grammar is **derived from what the comparison observes** — a `.cssig` may only express things +that affect a signature. The comparison looks at accessibility, `static`, virtuality, type-level +`abstract`/`sealed`, ctor accessibility (extensibility of protected members), field +`readonly`/`const` values, and return/field/event/parameter types and ref kinds; every other +modifier, member bodies, and non-`const` field initializers are invisible to it and are +rejected. This keeps a `.cssig` from silently claiming something the analyzer ignores; if a future +distinction (e.g. nullability) should matter, it is added to *both* the comparison and the grammar +together. + +The public-API-surface rules (which members count, how protected members on extensible types are +handled, implicit constructors and record members, etc.) and the canonical signature format are +ported from the Roslyn Public API analyzer. + +## Usage + +Add a package reference and drop a `.cssig` file next to your code: + +```xml + + + +``` + +All `*.cssig` files anywhere in the project are included by default (as `AdditionalFiles`) and +together define the project's entire public API surface. There is no shipped/unshipped split — every +`.cssig` file is treated the same. To opt out of the auto-include entirely, set +`false`. + +If a project has no `.cssig` files, there is nothing to enforce and the analyzer does nothing. + +## Generating `.cssig` files + +You don't have to write a `.cssig` file by hand. A code fix on `CSSIG002` regenerates the +signature file from the project's current public API, mirroring the Public API analyzer's +"Add to public API" fix. + +The bootstrap workflow: + +1. Create an empty `.cssig` file in the project (e.g. `Api.cssig`). +2. Build. Because the file declares nothing, **every** public member surfaces a `CSSIG002`. +3. In the editor, invoke the code fix on any `CSSIG002` and choose **Fix all** (document, project, + or solution). The fix rewrites the whole file from the current surface, so a single application + resolves every outstanding `CSSIG002` at once. + +The same fix keeps an existing `.cssig` up to date: when you add public members, apply the fix to +any new `CSSIG002` to regenerate the file with the additions. If a project has more than one +`.cssig` file, the fix targets the first one. + +The regenerated text is exactly what the analyzer expects to round-trip with zero diagnostics: +members are emitted as body-less declarations grouped by namespace and nesting, with non-signature +modifiers (`async`, `unsafe`, `volatile`, …) omitted. + +## Notes + +- Accessibility and type-kind keywords (`public`, `class`, …) are part of the C# you write, but the + comparison is over the *externally visible surface*: a member written without `public` simply + isn't part of the declared surface. +- Nullable reference type annotations (`string?`) are part of *source* equivalence only: a + `string`/`string?` difference is reported when source (or both) equivalence is enforced, and + ignored under binary equivalence. They are only meaningful when the project compiles with nullable + reference types enabled; otherwise every reference type is oblivious on both sides and matches. +- Editor support (syntax highlighting) for `.cssig` files is provided by the VS Code extension under + [`src/CsSig/vscode`](../vscode). diff --git a/src/CsSig/Analyzer/SignatureModel.cs b/src/CsSig/Analyzer/SignatureModel.cs new file mode 100644 index 0000000..5a8cbc3 --- /dev/null +++ b/src/CsSig/Analyzer/SignatureModel.cs @@ -0,0 +1,407 @@ +using System; +using System.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Reflection.Metadata; +using Microsoft.CodeAnalysis; + +namespace CsSig; + +/// +/// A canonical, structural reference to a type. Two type references from different compilations +/// are equal when they denote the same type, regardless of how they were written. +/// +internal abstract partial record TypeRef +{ + // Closed hierarchy: only the nested cases below may derive from TypeRef. + private TypeRef() { } + + public sealed record Named( + string Namespace, + TypeRef? ContainingType, + string Name, + 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; + public sealed record Dynamic : TypeRef + { + public static readonly Dynamic Instance = new(); + } +} + +partial record TypeRef +{ + public static TypeRef From(ITypeSymbol type) + { + switch (type) + { + case IArrayTypeSymbol array: + return new Array(From(array.ElementType), array.Rank); + + case IPointerTypeSymbol pointer: + return new Pointer(From(pointer.PointedAtType)); + + case ITypeParameterSymbol typeParameter: + return new TypeParameter( + typeParameter.Ordinal, + typeParameter.TypeParameterKind == TypeParameterKind.Method); + + case IDynamicTypeSymbol: + return Dynamic.Instance; + + case IFunctionPointerTypeSymbol functionPointer: + { + var signature = functionPointer.Signature; + var callingConventionTypes = EqArray.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))); + return new FunctionPointer( + 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; + return new Named( + @namespace, + container, + named.Name, + 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)); + } + } + + private static string NamespaceName(INamespaceSymbol? @namespace) + => @namespace is null || @namespace.IsGlobalNamespace + ? string.Empty + : @namespace.ToDisplayString(); +} + +internal enum ApiMemberKind +{ + Type, + Method, + Field, + Event, +} + +/// +/// The boolean aspects of a member that are common to both equivalences (changing any breaks both), +/// packed into a single value. A type uses // +/// ; a method or event uses plus the virtuality bits +/// (///); a +/// field uses //. The +/// / bits are shared between type-level and member-level +/// meanings, which never apply to the same member. +/// +[Flags] +internal enum MemberFlags +{ + None = 0, + Static = 1 << 0, + Abstract = 1 << 1, + Sealed = 1 << 2, + Virtual = 1 << 3, + Override = 1 << 4, + ReadOnly = 1 << 5, + HasConstantValue = 1 << 6, +} + +/// The source-only modifiers of a parameter, packed into a single value. +[Flags] +internal enum ParamModifiers +{ + None = 0, + Params = 1 << 0, + This = 1 << 1, + Optional = 1 << 2, + + /// + /// The parameter is ref readonly rather than in. The two share a binary calling + /// convention (see ), so this distinction affects source equivalence only. + /// + RefReadOnly = 1 << 3, +} + +/// +/// The nullable reference type annotations of a type, flattened in a deterministic pre-order walk of +/// its structure (one entry per type node, e.g. string? on the array and its element for +/// string?[]?). NRT annotations affect source equivalence only — they are erased to +/// attributes in metadata and never change a calling convention, so they are carried by the source +/// projection alone (mirroring how is source-only). Each +/// byte is the numeric (0 = oblivious, 1 = not annotated, 2 = +/// annotated). When the project compiles without nullable reference types every node is oblivious on +/// both the project and the .cssig side, so the annotations compare equal and nothing is +/// enforced. +/// +internal readonly record struct Nullability(EqArray Annotations) +{ + public static Nullability Of(ITypeSymbol type) + { + var builder = ImmutableArray.CreateBuilder(); + Walk(type, builder); + return new Nullability(EqArray.From(builder)); + } + + private static void Walk(ITypeSymbol type, ImmutableArray.Builder builder) + { + switch (type) + { + case IArrayTypeSymbol array: + builder.Add((byte)array.NullableAnnotation); + Walk(array.ElementType, builder); + break; + + case IPointerTypeSymbol pointer: + builder.Add((byte)pointer.NullableAnnotation); + Walk(pointer.PointedAtType, builder); + break; + + case ITypeParameterSymbol typeParameter: + builder.Add((byte)typeParameter.NullableAnnotation); + break; + + case IDynamicTypeSymbol: + builder.Add((byte)type.NullableAnnotation); + break; + + case IFunctionPointerTypeSymbol functionPointer: + builder.Add((byte)functionPointer.NullableAnnotation); + var signature = functionPointer.Signature; + Walk(signature.ReturnType, builder); + foreach (var parameter in signature.Parameters) + { + Walk(parameter.Type, builder); + } + + break; + + case INamedTypeSymbol named: + // A nested type's containing type can itself carry annotations (Outer.Inner), + // so descend into it before the type's own annotation and its type arguments. + if (named.ContainingType is { } containingType) + { + Walk(containingType, builder); + } + + builder.Add((byte)named.NullableAnnotation); + foreach (var argument in named.TypeArguments) + { + Walk(argument, builder); + } + + break; + + default: + builder.Add((byte)type.NullableAnnotation); + break; + } + } +} + +/// +/// The part of a parameter that contributes to a member's identity: callers can never tell +/// two members apart by anything else, so a difference here is an add/remove, not a modification. +/// The is the binary ref kind: in and ref readonly +/// collapse to because they share one calling convention; their source +/// difference is carried by instead. +/// +internal readonly record struct ParamKey(TypeRef Type, RefKind RefKind); + +/// +/// The part of a parameter that affects source equivalence only: its name (named +/// arguments), its source-only modifiers (params, extension this, optional, +/// 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); + +/// +/// The identity of an API member: the tuple by which two members from different compilations are +/// paired. A difference in identity is reported as an added/removed member, never a modification. +/// +internal sealed record MemberIdentity( + ApiMemberKind Kind, + string Namespace, + TypeRef? ContainingType, + string Name, + int Arity, + EqArray Parameters); + +/// +/// The aspects of a type declaration observable to every consumer (source or binary): +/// changing any breaks both equivalences. Shared by and +/// . +/// +internal sealed record CommonTypeAspects(MemberFlags Flags); + +/// The method aspects common to both equivalences (return type, static-ness, virtuality). +internal sealed record CommonMethodAspects(TypeRef ReturnType, MemberFlags Flags); + +/// The field aspects common to both equivalences (type, static-ness, readonly, const-ness). +internal sealed record CommonFieldAspects(TypeRef Type, MemberFlags Flags); + +/// The event aspects common to both equivalences (type, static-ness, virtuality). +internal sealed record CommonEventAspects(TypeRef Type, MemberFlags Flags); + +/// +/// The projection of a member that defines source equivalence. Two members are +/// source-equivalent exactly when their values are equal, so each case +/// carries the aspects common to both equivalences plus only the source-only aspects of its kind. +/// +internal abstract record SourceMember +{ + private SourceMember() { } + + public sealed record Type(CommonTypeAspects Common) : SourceMember; + + public sealed record Method( + CommonMethodAspects Common, Nullability ReturnNullability, EqArray Parameters) : SourceMember; + + public sealed record Field(CommonFieldAspects Common, Nullability Nullability) : SourceMember; + + public sealed record Event(CommonEventAspects Common, Nullability Nullability) : SourceMember; +} + +/// +/// The projection of a member that defines binary equivalence. Two members are +/// binary-equivalent exactly when their values are equal. It carries the +/// same common aspects as plus only the binary-only aspects of its kind +/// (the constant value baked into already-compiled consumers). +/// +internal abstract record BinaryMember +{ + private BinaryMember() { } + + public sealed record Type(CommonTypeAspects Common) : BinaryMember; + + public sealed record Method(CommonMethodAspects Common) : BinaryMember; + + public sealed record Field(CommonFieldAspects Common, string? ConstantValue) : BinaryMember; + + public sealed record Event(CommonEventAspects Common) : BinaryMember; +} + +/// +/// Structural representation of one externally-visible API member, decomposed into an +/// (the pairing key) and two projections whose record equality +/// is source/binary equivalence. The values may originate from different compilations +/// (the project and the synthetic .cssig compilation). +/// +internal sealed record ApiMember(MemberIdentity Identity, SourceMember Source, BinaryMember Binary) +{ + 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; + + // 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 + // false for kinds they do not apply to. + var flags = FlagsFrom(symbol); + + switch (symbol) + { + case INamedTypeSymbol named: + { + var identity = new MemberIdentity( + ApiMemberKind.Type, @namespace, containingType, named.Name, named.Arity, default); + var common = new CommonTypeAspects(flags); + 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)))); + var parameters = EqArray.From( + 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); + var common = new CommonMethodAspects( + TypeRef.From(method.ReturnType), + flags | (method.IsReadOnly ? MemberFlags.ReadOnly : MemberFlags.None)); + return new ApiMember( + identity, + new SourceMember.Method(common, Nullability.Of(method.ReturnType), parameters), + 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) + | (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)); + } + + case IEventSymbol @event: + { + var identity = new MemberIdentity( + 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)); + } + + default: + // ApiSurface only ever yields types, methods, fields, and events (see + // 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)); + } + } + + 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; + + // `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 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 new file mode 100644 index 0000000..25818b7 --- /dev/null +++ b/src/CsSig/Analyzer/StaticCs.CsSig.Analyzers.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + enable + latest + StaticCs.CsSig.Analyzers + true + + + false + + + + + + + + + diff --git a/src/CsSig/Analyzer/SymbolVisibility.cs b/src/CsSig/Analyzer/SymbolVisibility.cs new file mode 100644 index 0000000..7b2f82e --- /dev/null +++ b/src/CsSig/Analyzer/SymbolVisibility.cs @@ -0,0 +1,64 @@ +using Microsoft.CodeAnalysis; + +namespace CsSig; + +internal enum SymbolVisibility +{ + Public = 0, + Internal = 1, + Private = 2, +} + +internal static class SymbolVisibilityExtensions +{ + /// + /// Computes the effective visibility of a symbol, taking its containing types into account. + /// Ported from Roslyn's ISymbolExtensions.GetResultantVisibility + /// (dotnet/roslyn: src/Workspaces/SharedUtilitiesAndExtensions/Compiler/Extensions/Symbols/ISymbolExtensions.cs). + /// + public static SymbolVisibility GetResultantVisibility(this ISymbol symbol) + { + // Start by assuming it's visible. + var visibility = SymbolVisibility.Public; + + switch (symbol.Kind) + { + case SymbolKind.Alias: + // Aliases are only visible in the file they were declared in. + return SymbolVisibility.Private; + + case SymbolKind.Parameter: + // Parameters are only as visible as their containing symbol. + return GetResultantVisibility(symbol.ContainingSymbol); + + case SymbolKind.TypeParameter: + // Type parameters are private. + return SymbolVisibility.Private; + } + + ISymbol? current = symbol; + while (current != null && current.Kind != SymbolKind.Namespace) + { + switch (current.DeclaredAccessibility) + { + // If we see anything private, then the symbol is private. + case Accessibility.NotApplicable: + case Accessibility.Private: + return SymbolVisibility.Private; + + // If we see anything internal, then knock it down from public to internal. + case Accessibility.Internal: + case Accessibility.ProtectedAndInternal: + visibility = SymbolVisibility.Internal; + break; + + // For anything else (Public, Protected, ProtectedOrInternal), the + // symbol stays at the level we've gotten so far. + } + + current = current.ContainingSymbol; + } + + return visibility; + } +} diff --git a/src/CsSig/Analyzer/build/StaticCS.CsSig.props b/src/CsSig/Analyzer/build/StaticCS.CsSig.props new file mode 100644 index 0000000..2604cf4 --- /dev/null +++ b/src/CsSig/Analyzer/build/StaticCS.CsSig.props @@ -0,0 +1,24 @@ + + + + + + + + + + Both + + + + + + diff --git a/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs b/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs new file mode 100644 index 0000000..752256c --- /dev/null +++ b/src/CsSig/CodeFixes/CsSigCodeFixProvider.cs @@ -0,0 +1,451 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace CsSig; + +/// +/// Offers a fix for CSSIG002 (a public member missing from the .cssig files) that +/// (re)generates a signature file from the project's current public API via . +/// +/// +/// +/// Every .cssig file "owns" a set of top-level types — the ones it declares. Adding a missing +/// member is therefore the same operation regardless of where it lands: regenerate the owning file +/// from the project's current surface (which now includes the member). Because regeneration rewrites +/// the whole file from symbols, it is idempotent and automatically resolves every outstanding +/// CSSIG002 for the types that file owns at once. +/// +/// +/// If a file already declares the member's (top-level) type, the member is added there. If no file +/// declares it, the fix offers two destinations: the default PublicAPI.cssig, or a +/// per-type <TypeName>.cssig file. +/// +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(CsSigCodeFixProvider)), Shared] +public sealed class CsSigCodeFixProvider : CodeFixProvider +{ + internal const string Extension = ".cssig"; + internal const string DefaultFileName = "PublicAPI.cssig"; + + // Equivalence keys double as the FixAll destination strategy for *undeclared* types. + public const string ToPublicApiKey = "CsSig.AddToPublicApi"; + public const string ToTypeFileKey = "CsSig.AddToTypeFile"; + + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DiagId.MissingFromSignature.ToIdString()); + + public override FixAllProvider GetFixAllProvider() => CsSigFixAllProvider.Instance; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var document = context.Document; + var project = document.Project; + + var compilation = await project.GetCompilationAsync(context.CancellationToken).ConfigureAwait(false); + var model = await document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + var root = await document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (compilation is null || model is null || root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var symbol = ResolveMember(model, root, diagnostic.Location.SourceSpan, context.CancellationToken); + if (symbol is null) + { + return; + } + + var topLevel = TopLevelType(symbol); + if (topLevel is null) + { + return; + } + + var key = CsSigWriter.TopLevelKey(topLevel); + var index = await SignatureIndex.BuildAsync(project, context.CancellationToken).ConfigureAwait(false); + + if (index.OwnerOf(key) is { } owner) + { + // The type is already declared in a .cssig file: regenerate that file. + context.RegisterCodeFix( + CodeAction.Create( + $"Add missing API to '{owner.Name}'", + ct => RegenerateOwningFileAsync(project, owner, index, key, ct), + equivalenceKey: ToPublicApiKey), + diagnostic); + return; + } + + // The type is not declared anywhere: offer PublicAPI.cssig (default) or .cssig. + var perTypeName = topLevel.Name + Extension; + context.RegisterCodeFix( + CodeAction.Create( + $"Add API to '{DefaultFileName}'", + ct => AddTypeToNamedFileAsync(project, DefaultFileName, index, key, ct), + equivalenceKey: ToPublicApiKey), + diagnostic); + context.RegisterCodeFix( + CodeAction.Create( + $"Add API to '{perTypeName}'", + ct => AddTypeToNamedFileAsync(project, perTypeName, index, key, ct), + equivalenceKey: ToTypeFileKey), + diagnostic); + } + + /// Regenerates the file that already owns from the project's + /// current surface. + private static async Task RegenerateOwningFileAsync( + Project project, TextDocument owner, SignatureIndex index, string key, CancellationToken ct) + { + var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly; + if (assembly is null) + { + return project.Solution; + } + + var keys = new HashSet(index.KeysOwnedBy(owner.Id)) { key }; + var text = CsSigWriter.Write(assembly, keys); + return project.Solution.WithAdditionalDocumentText(owner.Id, SourceText.From(text)); + } + + /// Adds 's type to the file named , + /// creating it if necessary, otherwise regenerating it with the type included. + private static async Task AddTypeToNamedFileAsync( + Project project, string fileName, SignatureIndex index, string key, CancellationToken ct) + { + var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly; + if (assembly is null) + { + return project.Solution; + } + + var existing = index.DocumentNamed(fileName); + if (existing is not null) + { + var keys = new HashSet(index.KeysOwnedBy(existing.Id)) { key }; + var updated = CsSigWriter.Write(assembly, keys); + return project.Solution.WithAdditionalDocumentText(existing.Id, SourceText.From(updated)); + } + + var text = CsSigWriter.Write(assembly, new HashSet { key }); + return project + .AddAdditionalDocument(fileName, SourceText.From(text), filePath: FilePathFor(project, fileName)) + .Project.Solution; + } + + private static string FilePathFor(Project project, string fileName) + { + var dir = project.FilePath is { } p ? Path.GetDirectoryName(p) : null; + if (dir is null + && project.AdditionalDocuments.Select(d => d.FilePath).FirstOrDefault(d => d is not null) is { } existing) + { + dir = Path.GetDirectoryName(existing); + } + + return dir is null ? fileName : Path.Combine(dir, fileName); + } + + /// The outermost containing type of — the top-level type that + /// a signature file declares. Property/event accessors are normalized to their owning member. + private static INamedTypeSymbol? TopLevelType(ISymbol symbol) + { + var member = symbol is IMethodSymbol { AssociatedSymbol: { } associated } + && associated is IPropertySymbol or IEventSymbol + ? associated + : symbol; + + var type = member as INamedTypeSymbol ?? member.ContainingType; + if (type is null) + { + return null; + } + + while (type.ContainingType is { } outer) + { + type = outer; + } + + return type; + } + + /// Resolves the declared symbol the CSSIG002 diagnostic refers to, walking up from the + /// node at until a member or type symbol is found. + private static ISymbol? ResolveMember(SemanticModel model, SyntaxNode root, TextSpan span, CancellationToken ct) + { + var node = root.FindNode(span, getInnermostNodeForTie: true); + for (var current = node; current is not null; current = current.Parent) + { + var declared = model.GetDeclaredSymbol(current, ct); + if (declared is INamedTypeSymbol or IMethodSymbol or IPropertySymbol or IEventSymbol or IFieldSymbol) + { + return declared; + } + + if (current is BaseTypeDeclarationSyntax) + { + break; + } + } + + return null; + } + + private static bool IsSignatureFile(TextDocument doc) + => (doc.FilePath ?? doc.Name).EndsWith(Extension, System.StringComparison.OrdinalIgnoreCase); + + /// The keys (see ) of the top-level types declared + /// in a .cssig document. + private static async Task> TopLevelKeysAsync(TextDocument doc, CancellationToken ct) + { + var keys = new HashSet(); + var text = await doc.GetTextAsync(ct).ConfigureAwait(false); + var root = CSharpSyntaxTree.ParseText(text, cancellationToken: ct).GetRoot(ct); + + foreach (var type in root.DescendantNodes().OfType()) + { + // Top-level = not nested inside another type declaration. + if (type.Ancestors().OfType().Any()) + { + continue; + } + + keys.Add(SyntaxTopLevelKey(type)); + } + + return keys; + } + + private static string SyntaxTopLevelKey(BaseTypeDeclarationSyntax type) + { + var ns = NamespaceName(type); + var arity = type is TypeDeclarationSyntax { TypeParameterList: { } list } ? list.Parameters.Count : 0; + var name = arity > 0 ? type.Identifier.ValueText + "`" + arity : type.Identifier.ValueText; + return ns.Length > 0 ? ns + "." + name : name; + } + + private static string NamespaceName(SyntaxNode node) + { + var names = new List(); + for (var ancestor = node.Parent; ancestor is not null; ancestor = ancestor.Parent) + { + if (ancestor is BaseNamespaceDeclarationSyntax ns) + { + names.Add(ns.Name.ToString()); + } + } + + names.Reverse(); + return string.Join(".", names); + } + + /// An index of the project's .cssig additional documents and the top-level types + /// each one declares. + internal sealed class SignatureIndex + { + private readonly Dictionary _ownerByKey; + private readonly Dictionary> _keysByDocument; + private readonly List _documents; + + private SignatureIndex( + Dictionary ownerByKey, + Dictionary> keysByDocument, + List documents) + { + _ownerByKey = ownerByKey; + _keysByDocument = keysByDocument; + _documents = documents; + } + + public static async Task BuildAsync(Project project, CancellationToken ct) + { + var ownerByKey = new Dictionary(); + var keysByDocument = new Dictionary>(); + var documents = new List(); + + foreach (var doc in project.AdditionalDocuments) + { + if (!IsSignatureFile(doc)) + { + continue; + } + + documents.Add(doc); + var keys = await TopLevelKeysAsync(doc, ct).ConfigureAwait(false); + keysByDocument[doc.Id] = keys; + foreach (var key in keys) + { + ownerByKey[key] = doc; + } + } + + return new SignatureIndex(ownerByKey, keysByDocument, documents); + } + + public TextDocument? OwnerOf(string key) => _ownerByKey.TryGetValue(key, out var doc) ? doc : null; + + public IEnumerable KeysOwnedBy(DocumentId id) + => _keysByDocument.TryGetValue(id, out var keys) ? keys : Enumerable.Empty(); + + public TextDocument? DocumentNamed(string name) + => _documents.FirstOrDefault(d => string.Equals(d.Name, name, System.StringComparison.OrdinalIgnoreCase)); + } + + private sealed class CsSigFixAllProvider : FixAllProvider + { + public static readonly CsSigFixAllProvider Instance = new(); + + public override async Task GetFixAsync(FixAllContext fixAllContext) + { + var diagnostics = await GatherAsync(fixAllContext).ConfigureAwait(false); + if (diagnostics.IsEmpty) + { + return null; + } + + var strategy = fixAllContext.CodeActionEquivalenceKey ?? ToPublicApiKey; + return CodeAction.Create( + "Add missing API to .cssig files", + ct => ApplyAsync(fixAllContext.Solution, fixAllContext.Project, diagnostics, strategy, ct), + equivalenceKey: strategy); + } + + private static async Task> GatherAsync(FixAllContext context) + { + switch (context.Scope) + { + case FixAllScope.Document when context.Document is { } document: + return await context.GetDocumentDiagnosticsAsync(document).ConfigureAwait(false); + case FixAllScope.Project: + return await context.GetAllDiagnosticsAsync(context.Project).ConfigureAwait(false); + case FixAllScope.Solution: + var all = ImmutableArray.CreateBuilder(); + foreach (var project in context.Solution.Projects) + { + all.AddRange(await context.GetAllDiagnosticsAsync(project).ConfigureAwait(false)); + } + + return all.ToImmutable(); + default: + return ImmutableArray.Empty; + } + } + + /// + /// Regenerates every signature file touched by the batch. Each missing member is routed to the + /// file that owns its top-level type (or, for an undeclared type, to PublicAPI.cssig or a + /// per-type file per ); each touched file is then regenerated once + /// from the project surface with the full set of types it should own. + /// + private static async Task ApplyAsync( + Solution solution, Project project, ImmutableArray diagnostics, string strategy, CancellationToken ct) + { + var assembly = (await project.GetCompilationAsync(ct).ConfigureAwait(false))?.Assembly; + if (assembly is null) + { + return solution; + } + + var index = await SignatureIndex.BuildAsync(project, ct).ConfigureAwait(false); + + // fileName -> the set of top-level keys that file should own after the fix. + var plan = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); + var existingByName = new Dictionary(System.StringComparer.OrdinalIgnoreCase); + + foreach (var diagnostic in diagnostics) + { + ct.ThrowIfCancellationRequested(); + + var key = await ResolveKeyAsync(solution, diagnostic, ct).ConfigureAwait(false); + if (key is null) + { + continue; + } + + var target = index.OwnerOf(key); + string fileName; + if (target is not null) + { + fileName = target.Name; + } + else + { + fileName = strategy == ToTypeFileKey + ? TypeNameFromKey(key) + Extension + : DefaultFileName; + target = index.DocumentNamed(fileName); + } + + if (!plan.TryGetValue(fileName, out var keys)) + { + keys = target is not null + ? new HashSet(index.KeysOwnedBy(target.Id)) + : new HashSet(); + plan[fileName] = keys; + if (target is not null) + { + existingByName[fileName] = target; + } + } + + keys.Add(key); + } + + foreach (var entry in plan) + { + var text = SourceText.From(CsSigWriter.Write(assembly, entry.Value)); + if (existingByName.TryGetValue(entry.Key, out var doc)) + { + solution = solution.WithAdditionalDocumentText(doc.Id, text); + } + else + { + var id = DocumentId.CreateNewId(project.Id); + solution = solution.AddAdditionalDocument( + id, entry.Key, text, filePath: FilePathFor(project, entry.Key)); + } + } + + return solution; + } + + private static async Task ResolveKeyAsync(Solution solution, Diagnostic diagnostic, CancellationToken ct) + { + if (diagnostic.Location.SourceTree is not { } tree + || solution.GetDocument(tree) is not { } document) + { + return null; + } + + var model = await document.GetSemanticModelAsync(ct).ConfigureAwait(false); + var root = await document.GetSyntaxRootAsync(ct).ConfigureAwait(false); + if (model is null || root is null) + { + return null; + } + + var symbol = ResolveMember(model, root, diagnostic.Location.SourceSpan, ct); + var topLevel = symbol is null ? null : TopLevelType(symbol); + return topLevel is null ? null : CsSigWriter.TopLevelKey(topLevel); + } + + private static string TypeNameFromKey(string key) + { + var lastDot = key.LastIndexOf('.'); + var name = lastDot >= 0 ? key.Substring(lastDot + 1) : key; + var backtick = name.IndexOf('`'); + return backtick >= 0 ? name.Substring(0, backtick) : name; + } + } +} diff --git a/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj b/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj new file mode 100644 index 0000000..7df923a --- /dev/null +++ b/src/CsSig/CodeFixes/StaticCs.CsSig.CodeFixes.csproj @@ -0,0 +1,48 @@ + + + + netstandard2.0 + enable + latest + StaticCs.CsSig.CodeFixes + true + + + + + StaticCS.CsSig + 0.1.0 + agocke + true + BSD-3-Clause + https://github.com/agocke/static-cs + An analyzer that checks a project's public API surface against C# signature (.cssig) files, with a code fix that generates them. + README.md + + false + true + true + + + + + + + + + + + + + + + + diff --git a/src/CsSig/vscode/README.md b/src/CsSig/vscode/README.md new file mode 100644 index 0000000..8aa0be4 --- /dev/null +++ b/src/CsSig/vscode/README.md @@ -0,0 +1,23 @@ +# C# Signature (`.cssig`) — VS Code support + +Syntax highlighting and basic editor configuration (brackets, comments, auto-closing pairs) for +`.cssig` files used by the [CsSig analyzer](../../../src/CsSig). + +A `.cssig` file is ordinary C# describing a project's public API surface, with member declarations +written without bodies. + +## Install (local development) + +Copy or symlink this folder into your VS Code extensions directory: + +```sh +ln -s "$(pwd)" ~/.vscode/extensions/static-cs.cssig-0.1.0 +``` + +Then reload VS Code. Files ending in `.cssig` will be highlighted using the `source.cssig` grammar. + +## Contents + +- `package.json` — language + grammar contribution. +- `language-configuration.json` — comments, brackets, auto-closing/surrounding pairs. +- `syntaxes/cssig.tmLanguage.json` — the TextMate grammar (`scopeName: source.cssig`). diff --git a/src/CsSig/vscode/language-configuration.json b/src/CsSig/vscode/language-configuration.json new file mode 100644 index 0000000..d938d54 --- /dev/null +++ b/src/CsSig/vscode/language-configuration.json @@ -0,0 +1,71 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": [ + "/*", + "*/" + ] + }, + "brackets": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ], + [ + "<", + ">" + ] + ], + "autoClosingPairs": [ + { + "open": "{", + "close": "}" + }, + { + "open": "[", + "close": "]" + }, + { + "open": "(", + "close": ")" + }, + { + "open": "\"", + "close": "\"", + "notIn": [ + "string", + "comment" + ] + } + ], + "surroundingPairs": [ + [ + "{", + "}" + ], + [ + "[", + "]" + ], + [ + "(", + ")" + ], + [ + "<", + ">" + ], + [ + "\"", + "\"" + ] + ] +} diff --git a/src/CsSig/vscode/package.json b/src/CsSig/vscode/package.json new file mode 100644 index 0000000..51331ea --- /dev/null +++ b/src/CsSig/vscode/package.json @@ -0,0 +1,40 @@ +{ + "name": "cssig", + "displayName": "C# Signature (.cssig)", + "description": "Syntax highlighting for C# signature (.cssig) files used by the CsSig analyzer.", + "version": "0.1.0", + "publisher": "static-cs", + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "https://github.com/agocke/static-cs" + }, + "engines": { + "vscode": "^1.60.0" + }, + "categories": [ + "Programming Languages" + ], + "contributes": { + "languages": [ + { + "id": "cssig", + "aliases": [ + "C# Signature", + "cssig" + ], + "extensions": [ + ".cssig" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "cssig", + "scopeName": "source.cssig", + "path": "./syntaxes/cssig.tmLanguage.json" + } + ] + } +} diff --git a/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json b/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json new file mode 100644 index 0000000..e3ab77c --- /dev/null +++ b/src/CsSig/vscode/syntaxes/cssig.tmLanguage.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "C# Signature", + "scopeName": "source.cssig", + "patterns": [ + { + "include": "#comment" + }, + { + "include": "#directive" + }, + { + "include": "#attribute" + }, + { + "include": "#namespace" + }, + { + "include": "#type-declaration" + }, + { + "include": "#keyword" + }, + { + "include": "#storage-type" + }, + { + "include": "#string" + }, + { + "include": "#char" + }, + { + "include": "#number" + }, + { + "include": "#punctuation" + }, + { + "include": "#identifier" + } + ], + "repository": { + "comment": { + "patterns": [ + { + "name": "comment.block.documentation.cssig", + "begin": "///", + "end": "$" + }, + { + "name": "comment.line.double-slash.cssig", + "begin": "//", + "end": "$" + }, + { + "name": "comment.block.cssig", + "begin": "/\\*", + "end": "\\*/" + } + ] + }, + "directive": { + "name": "meta.preprocessor.cssig", + "begin": "^\\s*#\\s*(nullable|define|undef|if|elif|else|endif|region|endregion|pragma|warning|error|line)\\b", + "beginCaptures": { + "0": { + "name": "keyword.control.directive.cssig" + } + }, + "end": "$" + }, + "attribute": { + "name": "meta.attribute.cssig", + "begin": "(\\[)(?=\\s*[A-Za-z_])", + "beginCaptures": { + "1": { + "name": "punctuation.section.attribute.begin.cssig" + } + }, + "end": "(\\])", + "endCaptures": { + "1": { + "name": "punctuation.section.attribute.end.cssig" + } + }, + "patterns": [ + { + "include": "#comment" + }, + { + "match": "[A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)*", + "name": "entity.name.type.attribute.cssig" + }, + { + "include": "#string" + }, + { + "include": "#number" + }, + { + "include": "#punctuation" + } + ] + }, + "namespace": { + "match": "\\b(namespace)\\s+([A-Za-z_][A-Za-z0-9_]*(?:\\s*\\.\\s*[A-Za-z_][A-Za-z0-9_]*)*)", + "captures": { + "1": { + "name": "keyword.other.namespace.cssig" + }, + "2": { + "name": "entity.name.type.namespace.cssig" + } + } + }, + "type-declaration": { + "match": "\\b(class|struct|interface|enum|record|delegate)\\b(?:\\s+(class|struct))?\\s+([A-Za-z_][A-Za-z0-9_]*)", + "captures": { + "1": { + "name": "keyword.other.cssig storage.type.cssig" + }, + "2": { + "name": "keyword.other.cssig storage.type.cssig" + }, + "3": { + "name": "entity.name.type.cssig" + } + } + }, + "keyword": { + "patterns": [ + { + "name": "storage.modifier.cssig", + "match": "\\b(public|private|protected|internal|static|abstract|sealed|virtual|override|readonly|const|extern|partial|new|unsafe|volatile|async|required|file|in|out|ref|params)\\b" + }, + { + "name": "keyword.other.cssig", + "match": "\\b(using|namespace|class|struct|interface|enum|record|delegate|event|where|operator|implicit|explicit|get|set|init|add|remove|this|base|global|nameof|typeof|default)\\b" + } + ] + }, + "storage-type": { + "name": "storage.type.builtin.cssig keyword.type.cssig", + "match": "\\b(void|bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|string|object|nint|nuint|dynamic|var)\\b" + }, + "string": { + "patterns": [ + { + "name": "string.quoted.double.verbatim.cssig", + "begin": "@\"", + "end": "\"(?!\")", + "patterns": [ + { + "match": "\"\"", + "name": "constant.character.escape.cssig" + } + ] + }, + { + "name": "string.quoted.double.cssig", + "begin": "\\$?\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.cssig", + "match": "\\\\." + } + ] + } + ] + }, + "char": { + "name": "string.quoted.single.cssig", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.cssig", + "match": "\\\\." + } + ] + }, + "number": { + "name": "constant.numeric.cssig", + "match": "\\b(0[xX][0-9a-fA-F_]+|0[bB][01_]+|[0-9_]+(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)([uUlLfFdDmM]{0,2})\\b" + }, + "punctuation": { + "patterns": [ + { + "name": "punctuation.terminator.statement.cssig", + "match": ";" + }, + { + "name": "punctuation.separator.cssig", + "match": "[,.:]" + }, + { + "name": "punctuation.section.brackets.cssig", + "match": "[\\[\\]{}()]" + }, + { + "name": "keyword.operator.cssig", + "match": "[<>=&|+\\-*/%!?~]" + } + ] + }, + "identifier": { + "patterns": [ + { + "match": "([A-Za-z_][A-Za-z0-9_]*)\\s*(?=<|\\()", + "captures": { + "1": { + "name": "entity.name.function.cssig" + } + } + }, + { + "match": "\\b([A-Z][A-Za-z0-9_]*)\\b", + "name": "entity.name.type.cssig" + }, + { + "name": "variable.other.cssig", + "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" + } + ] + } + } +} diff --git a/static-cs.sln b/static-cs.sln index c9c99d9..4d615a9 100644 --- a/static-cs.sln +++ b/static-cs.sln @@ -23,6 +23,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.Result", "src\Resu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IndentingBuilder", "src\IndentingBuilder\IndentingBuilder.csproj", "{3EBB37BC-0309-4562-9785-0CC2D9D23944}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.CsSig.Analyzers", "src\CsSig\Analyzer\StaticCs.CsSig.Analyzers.csproj", "{E7295DA3-FB09-4894-8C72-0E803A43A421}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StaticCs.CsSig.CodeFixes", "src\CsSig\CodeFixes\StaticCs.CsSig.CodeFixes.csproj", "{1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +133,30 @@ Global {3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x64.Build.0 = Release|Any CPU {3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x86.ActiveCfg = Release|Any CPU {3EBB37BC-0309-4562-9785-0CC2D9D23944}.Release|x86.Build.0 = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x64.Build.0 = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Debug|x86.Build.0 = Debug|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|Any CPU.Build.0 = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x64.ActiveCfg = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x64.Build.0 = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x86.ActiveCfg = Release|Any CPU + {E7295DA3-FB09-4894-8C72-0E803A43A421}.Release|x86.Build.0 = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x64.ActiveCfg = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x64.Build.0 = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Debug|x86.Build.0 = Debug|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|Any CPU.Build.0 = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x64.ActiveCfg = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x64.Build.0 = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x86.ActiveCfg = Release|Any CPU + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -142,5 +170,7 @@ Global {B29FFDB1-7006-42FA-9647-3101CDFB364F} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426} {8FF14261-BA9C-487F-8581-3F7D978EA772} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426} {3EBB37BC-0309-4562-9785-0CC2D9D23944} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426} + {E7295DA3-FB09-4894-8C72-0E803A43A421} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426} + {1BC89E4E-BC2F-4BF3-82D1-1AD58F62FCAF} = {E383093D-99E0-4B3D-8B3B-C960B7B7C426} EndGlobalSection EndGlobal diff --git a/test/test/CsSigTests.cs b/test/test/CsSigTests.cs new file mode 100644 index 0000000..ba17761 --- /dev/null +++ b/test/test/CsSigTests.cs @@ -0,0 +1,1181 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CsSig; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Text; +using Xunit; + +namespace StaticCs.Tests; + +public class CsSigTests +{ + [Fact] + public async Task ExactMatchProducesNoDiagnostics() + { + var source = """ + namespace N; + public class C + { + public int M(string s) => s.Length; + public string Name { get; set; } = ""; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(string s); + public string Name { get; set; } + } + """; + var diagnostics = await RunAsync(source, sig); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task SignatureMissingFromProjectReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(); + public int Extra(); + } + """; + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG001", diagnostic.Id); + Assert.Contains("Extra", diagnostic.GetMessage()); + } + + [Fact] + public async Task PublicMemberMissingFromSignatureReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + public int Extra() => 1; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(); + } + """; + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG002", diagnostic.Id); + Assert.Contains("Extra", diagnostic.GetMessage()); + } + + [Fact] + public async Task InternalAndPrivateMembersAreIgnored() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + internal int Hidden() => 1; + private int Secret() => 2; + } + internal class NotPublic + { + public int Whatever() => 0; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(); + } + """; + Assert.Empty(await RunAsync(source, sig)); + } + + [Fact] + public async Task ChangedReturnTypeReportsMismatch() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var sig = """ + namespace N; + public class C + { + public string M(); + } + """; + // The return type is a common aspect, not part of the member's identity, so the members + // pair up and the difference is one mismatch (breaking both equivalences), not add/remove. + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + Assert.Contains("source and binary", diagnostic.GetMessage()); + } + + [Fact] + public async Task MissingTypeIsReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(); + } + public class Missing + { + } + """; + var diagnostics = await RunAsync(source, sig); + // The missing type and its implicit constructor are both absent from the project. + Assert.All(diagnostics, d => Assert.Equal("CSSIG001", d.Id)); + Assert.Contains(diagnostics, d => d.GetMessage().Contains("N.Missing")); + } + + [Fact] + public async Task NoSignatureFilesMeansNoEnforcement() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + public int Extra() => 1; + } + """; + Assert.Empty(await RunAsync(source)); + } + + [Fact] + public async Task EnumExactMatch() + { + var source = """ + namespace N; + public enum Color { Red, Green, Blue } + """; + var sig = """ + namespace N; + public enum Color { Red, Green, Blue } + """; + Assert.Empty(await RunAsync(source, sig)); + } + + [Fact] + public async Task MissingEnumMemberReported() + { + var source = """ + namespace N; + public enum Color { Red, Green, Blue } + """; + var sig = """ + namespace N; + public enum Color { Red, Green } + """; + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG002", diagnostic.Id); + Assert.Contains("Blue", diagnostic.GetMessage()); + } + + [Fact] + public async Task SyntaxErrorInSignatureFileReported() + { + var source = """ + namespace N; + public class C + { + } + """; + var sig = """ + namespace N + public class C { + """; + var diagnostics = await RunAsync(source, sig); + Assert.Contains(diagnostics, d => d.Id == "CSSIG003"); + } + + [Fact] + public async Task PartialInSignatureFileReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var sig = """ + namespace N; + public partial class C + { + public partial int M(); + } + """; + var diagnostics = await RunAsync(source, sig); + Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004")); + } + + [Fact] + public async Task MemberBodyInSignatureFileReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + public int P { get; } + } + """; + var sig = """ + namespace N; + public class C + { + public int M() => 0; + public int P { get { return 0; } } + } + """; + var diagnostics = await RunAsync(source, sig); + // The expression body on M and the block body on P's getter are both rejected. + Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004")); + } + + [Fact] + public async Task ModifiersThatDoNotAffectTheSignatureAreReported() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + public int F; + } + """; + var sig = """ + namespace N; + public class C + { + public async int M(); + public volatile int F; + } + """; + var diagnostics = await RunAsync(source, sig); + // 'async' (method) and 'volatile' (field) are invisible to the comparison: both rejected, + // and the signatures still match so there are no missing/mismatch diagnostics. + Assert.Equal(2, diagnostics.Count(d => d.Id == "CSSIG004")); + Assert.DoesNotContain(diagnostics, d => d.Id is "CSSIG001" or "CSSIG002" or "CSSIG005"); + } + + [Fact] + public async Task ModifiersThatAffectVirtualityAreAllowedAndCompared() + { + var source = """ + namespace N; + public abstract class C + { + public virtual int M() => 0; + public abstract int N(); + } + """; + var sig = """ + namespace N; + public abstract class C + { + public virtual int M(); + public abstract int N(); + } + """; + // 'abstract' (type) and 'virtual'/'abstract' (members) affect both equivalences, so they + // are allowed and, matching the project, produce no diagnostics. + Assert.Empty(await RunAsync(source, sig)); + } + + [Fact] + public async Task VirtualityMismatchReportedInBothEquivalences() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var sig = """ + namespace N; + public class C + { + public virtual int M(); + } + """; + // Virtuality is a common aspect: declaring 'virtual' when the project's member is not + // breaks both equivalences. + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + Assert.Contains("source and binary", diagnostic.GetMessage()); + } + + [Fact] + public async Task ParameterNameChangeBreaksOnlySourceEquivalence() + { + var source = """ + namespace N; + public class C + { + public int M(string s) => s.Length; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(string name); + } + """; + // A renamed parameter changes named-argument call sites (source) but not the binary + // calling convention. + var underBoth = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", underBoth.Id); + Assert.Contains("breaks source equivalence", underBoth.GetMessage()); + + Assert.Empty(await RunWithEquivalenceAsync(source, "Binary", sig)); + } + + [Fact] + public async Task InVsRefReadonlyBreaksOnlySourceEquivalence() + { + var source = """ + namespace N; + public class C + { + public int M(ref readonly int x) => x; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(in int x); + } + """; + // `in` and `ref readonly` share a binary calling convention (both a modreq(InAttribute) + // byref), so they differ for source equivalence (call-site rules) but are binary-equivalent + // -- a single source-only modification, not an add/remove. + var underBoth = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", underBoth.Id); + Assert.Contains("breaks source equivalence", underBoth.GetMessage()); + + Assert.Empty(await RunWithEquivalenceAsync(source, "Binary", sig)); + } + + [Fact] + public async Task ConstValueChangeBreaksOnlyBinaryEquivalence() + { + var source = """ + namespace N; + public class C + { + public const int X = 1; + } + """; + var sig = """ + namespace N; + public class C + { + public const int X = 2; + } + """; + // A changed const value is baked into already-compiled consumers (binary) but a source + // recompile picks up the new value. + var underBinary = Assert.Single(await RunWithEquivalenceAsync(source, "Binary", sig)); + Assert.Equal("CSSIG005", underBinary.Id); + Assert.Contains("breaks binary equivalence", underBinary.GetMessage()); + + Assert.Empty(await RunWithEquivalenceAsync(source, "Source", sig)); + } + + [Fact] + public async Task FunctionPointerTypesAreComparedStructurally() + { + // The project source needs 'unsafe' (real C#); the .cssig declares the same member + // without it, since 'unsafe' has no signature impact and is rejected in .cssig. + var source = """ + namespace N; + public unsafe class C + { + public delegate* F; + } + """; + var match = """ + namespace N; + public class C + { + public delegate* F; + } + """; + // Identical function-pointer signatures are equivalent. + Assert.Empty(await RunAsync(source, match)); + + // A differing function-pointer parameter type (int vs long) must be detected + // structurally, not by a string blob. + var sig = """ + namespace N; + public class C + { + public delegate* F; + } + """; + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + Assert.Contains("source and binary", diagnostic.GetMessage()); + } + + [Fact] + public async Task UnsafeModifierInSignatureFileReported() + { + var source = """ + namespace N; + public unsafe class C + { + public delegate* F; + } + """; + // 'unsafe' has no signature impact, so it is rejected even though it is required to write + // the equivalent declaration in real C#. + var sig = """ + namespace N; + public unsafe class C + { + public delegate* F; + } + """; + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG004", diagnostic.Id); + } + + [Fact] + public async Task NonConstFieldInitializerReported() + { + var source = """ + namespace N; + public class C + { + public static int X; + } + """; + var sig = """ + namespace N; + public class C + { + public static int X = 5; + } + """; + var diagnostics = await RunAsync(source, sig); + // The initializer is invisible to the comparison (the field is not const), so it is + // rejected, but the fields themselves still match. + Assert.Equal(1, diagnostics.Count(d => d.Id == "CSSIG004")); + Assert.DoesNotContain(diagnostics, d => d.Id is "CSSIG001" or "CSSIG002"); + } + + [Fact] + public async Task RoundTripClassMembers() + { + await AssertRoundTripsAsync(""" + namespace N + { + public abstract class C + { + public const int K = 5; + public static readonly string S; + protected C() { } + public C(int x) { } + public virtual int M(T value, in int by, ref string s, out bool b) { b = true; return 0; } + public string Name { get; set; } + public int ReadOnly { get; } + public int this[int i] => i; + public event System.Action E; + } + } + """); + } + + [Fact] + public async Task RoundTripStructInterfaceEnumDelegate() + { + await AssertRoundTripsAsync(""" + namespace N + { + public interface IThing + { + int Compute(string s); + int Value { get; set; } + } + + public struct Point + { + public int X; + public int Y; + public readonly int Sum() => X + Y; + } + + public enum Color : byte + { + Red = 1, + Green = 2, + Blue = 4, + } + + public delegate int Transform(T input, ref int state); + } + """); + } + + [Fact] + public async Task RoundTripNestedTypesAndStaticClass() + { + await AssertRoundTripsAsync(""" + namespace N.Inner + { + public static class Helpers + { + public static int Add(int a, int b) => a + b; + + public sealed class Nested + { + public int Value; + } + } + } + """); + } + + [Fact] + public async Task RoundTripFunctionPointerAndVolatile() + { + await AssertRoundTripsAsync(""" + namespace N + { + public unsafe class C + { + public static volatile int Flag; + public delegate* Callback; + } + } + """); + } + + /// 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) + { + var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true); + if (nullable) + { + compilationOptions = compilationOptions.WithNullableContextOptions(NullableContextOptions.Enable); + } + + var compilation = CSharpCompilation.Create( + "TestProject", + new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") }, + references, + compilationOptions); + + var generated = CsSigWriter.Write(compilation); + var diagnostics = nullable + ? await RunNullableAsync(source, generated) + : await RunAsync(source, generated); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ReadonlyStructMethodMismatchReported() + { + var source = """ + namespace N; + public struct S + { + public int X; + public readonly int Get() => X; + } + """; + var sig = """ + namespace N; + public struct S + { + public int X; + public int Get(); + } + """; + // The project method is `readonly`; the signature's is not. That difference affects the + // calling convention of `this`, so it must be reported under both equivalences. + var diagnostic = Assert.Single(await RunAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + } + + [Fact] + public async Task NullableReturnAnnotationMismatchReported() + { + var source = """ + namespace N; + public class C + { + public string? M() => null; + } + """; + var sig = """ + namespace N; + public class C + { + public string M(); + } + """; + + // The project returns `string?` but the signature declares `string`: a nullable-annotation + // difference. It is observable only in source, so it reports as a source-equivalence change. + var diagnostic = Assert.Single(await RunNullableAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + } + + [Fact] + public async Task NullableParameterAnnotationMismatchReported() + { + var source = """ + namespace N; + public class C + { + public void M(string? s) { } + } + """; + var sig = """ + namespace N; + public class C + { + public void M(string s); + } + """; + + var diagnostic = Assert.Single(await RunNullableAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + } + + [Fact] + public async Task NullableFieldAnnotationMismatchReported() + { + var source = """ + namespace N; + public class C + { + public string? F; + } + """; + var sig = """ + namespace N; + public class C + { + public string F; + } + """; + + var diagnostic = Assert.Single(await RunNullableAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + } + + [Fact] + public async Task NullableNestedTypeArgumentAnnotationMismatchReported() + { + var source = """ + using System.Collections.Generic; + namespace N; + public class C + { + public List M() => new(); + } + """; + var sig = """ + using System.Collections.Generic; + namespace N; + public class C + { + public List M(); + } + """; + + // The outer type matches; only the nullability of the type argument differs. + var diagnostic = Assert.Single(await RunNullableAsync(source, sig)); + Assert.Equal("CSSIG005", diagnostic.Id); + } + + [Fact] + public async Task NullableAnnotationIgnoredUnderBinaryEquivalence() + { + var source = """ + namespace N; + public class C + { + public string? M() => null; + } + """; + var sig = """ + namespace N; + public class C + { + public string M(); + } + """; + + // Nullable annotations have no binary impact, so a `string?`/`string` difference is invisible + // when only binary equivalence is enforced. + Assert.Empty(await RunNullableWithEquivalenceAsync(source, "binary", sig)); + } + + [Fact] + public async Task MatchingNullableAnnotationAccepted() + { + var source = """ + namespace N; + public class C + { + public string? M(string? s) => s; + public string N(string s) => s; + } + """; + var sig = """ + namespace N; + public class C + { + public string? M(string? s); + public string N(string s); + } + """; + + Assert.Empty(await RunNullableAsync(source, sig)); + } + + [Fact] + public async Task NullableMembersRoundTrip() + { + await AssertRoundTripsAsync( + """ + using System.Collections.Generic; + namespace N; + public class C + { + public string? Field; + public string? Property { get; set; } + public string? M(string? a, List b) => a; + } + """, + nullable: true); + } + + [Fact] + public async Task CodeFixRegeneratesFileThatAlreadyDeclaresTheType() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + public int Extra() => 1; + } + """; + var sig = """ + namespace N; + public class C + { + public int M(); + } + """; + + // 'MyApi.cssig' already declares N.C, so the only offered fix regenerates that file. + using var harness = await CodeFixHarness.CreateAsync(source, ("MyApi.cssig", sig)); + var actions = await harness.RegisterFirstAsync(); + + var single = Assert.Single(actions); + Assert.Equal("Add missing API to 'MyApi.cssig'", single.Title); + + var files = await CodeFixHarness.ApplyAsync(single, harness.ProjectId); + Assert.Contains("public int Extra();", files["MyApi.cssig"]); + Assert.Empty(await RunAsync(source, files["MyApi.cssig"])); + } + + [Fact] + public async Task CodeFixDefaultPopulatesPublicApiFileForUndeclaredType() + { + var source = """ + namespace N; + public class C + { + public int M(string s) => s.Length; + public string Name { get; set; } = ""; + } + """; + + // Bootstrap workflow: an empty PublicAPI.cssig exists but declares nothing, so N.C is + // undeclared. Two fixes are offered; the default (first) targets PublicAPI.cssig. + using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", "")); + var actions = await harness.RegisterFirstAsync(); + + Assert.Equal(2, actions.Length); + Assert.Equal("Add API to 'PublicAPI.cssig'", actions[0].Title); + Assert.Equal("Add API to 'C.cssig'", actions[1].Title); + + var files = await CodeFixHarness.ApplyAsync(actions[0], harness.ProjectId); + var generated = files["PublicAPI.cssig"]; + Assert.Contains("public int M(string s);", generated); + Assert.Contains("public string Name { get; set; }", generated); + Assert.Empty(await RunAsync(source, generated)); + } + + [Fact] + public async Task CodeFixPerTypeOptionCreatesTypeNamedFile() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + + using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", "")); + var actions = await harness.RegisterFirstAsync(); + + // The second option writes into .cssig, leaving PublicAPI.cssig untouched. + var files = await CodeFixHarness.ApplyAsync(actions[1], harness.ProjectId); + Assert.Contains("public int M();", files["C.cssig"]); + Assert.Equal("", files["PublicAPI.cssig"]); + Assert.Empty(await RunAsync(source, files["C.cssig"])); + } + + [Fact] + public async Task CodeFixRoutesToDeclaringFileNotPublicApi() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + """; + var cFile = """ + namespace N; + public class C + { + } + """; + + // N.C is declared in C.cssig, so even with PublicAPI.cssig present the fix targets C.cssig. + using var harness = await CodeFixHarness.CreateAsync( + source, ("PublicAPI.cssig", ""), ("C.cssig", cFile)); + var actions = await harness.RegisterFirstAsync(); + + var single = Assert.Single(actions); + Assert.Equal("Add missing API to 'C.cssig'", single.Title); + + var files = await CodeFixHarness.ApplyAsync(single, harness.ProjectId); + Assert.Contains("public int M();", files["C.cssig"]); + Assert.Equal("", files["PublicAPI.cssig"]); + } + + [Fact] + public async Task CodeFixFixAllPopulatesPublicApiWithEveryType() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + public class D + { + public int N() => 0; + } + """; + + using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", "")); + var files = await harness.ApplyFixAllAsync(CsSigCodeFixProvider.ToPublicApiKey); + + var generated = files["PublicAPI.cssig"]; + Assert.Contains("class C", generated); + Assert.Contains("class D", generated); + Assert.Empty(await RunAsync(source, generated)); + } + + [Fact] + public async Task CodeFixFixAllPerTypeCreatesOneFileEach() + { + var source = """ + namespace N; + public class C + { + public int M() => 0; + } + public class D + { + public int N() => 0; + } + """; + + using var harness = await CodeFixHarness.CreateAsync(source, ("PublicAPI.cssig", "")); + var files = await harness.ApplyFixAllAsync(CsSigCodeFixProvider.ToTypeFileKey); + + Assert.Contains("public int M();", files["C.cssig"]); + Assert.Contains("public int N();", files["D.cssig"]); + Assert.Equal("", files["PublicAPI.cssig"]); + Assert.Empty(await RunAsync(source, files["C.cssig"], files["D.cssig"])); + } + + /// An in-memory workspace harness for exercising : + /// builds a project with a source document plus named .cssig additional documents, runs + /// the analyzer, and exposes helpers to register and apply the resulting fixes. + private sealed class CodeFixHarness : System.IDisposable + { + private readonly Microsoft.CodeAnalysis.AdhocWorkspace _workspace; + + public ProjectId ProjectId { get; } + public DocumentId SourceId { get; } + + private CodeFixHarness(Microsoft.CodeAnalysis.AdhocWorkspace workspace, ProjectId projectId, DocumentId sourceId) + { + _workspace = workspace; + ProjectId = projectId; + SourceId = sourceId; + } + + public void Dispose() => _workspace.Dispose(); + + public static async Task CreateAsync(string source, params (string name, string content)[] sigFiles) + { + var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); + + var projectId = ProjectId.CreateNewId(); + var sourceId = DocumentId.CreateNewId(projectId); + + var workspace = new Microsoft.CodeAnalysis.AdhocWorkspace(); + var solution = workspace.CurrentSolution + .AddProject(projectId, "TestProject", "TestProject", LanguageNames.CSharp) + .AddMetadataReferences(projectId, references) + .WithProjectCompilationOptions( + projectId, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)) + .AddDocument(sourceId, "Test.cs", source); + + foreach (var (name, content) in sigFiles) + { + var sigId = DocumentId.CreateNewId(projectId); + solution = solution.AddAdditionalDocument( + sigId, name, SourceText.From(content), filePath: name); + } + + Assert.True(workspace.TryApplyChanges(solution)); + return new CodeFixHarness(workspace, projectId, sourceId); + } + + private Project Project => _workspace.CurrentSolution.GetProject(ProjectId)!; + + private async Task> MissingDiagnosticsAsync() + { + var project = Project; + var compilation = (await project.GetCompilationAsync(CancellationToken.None))!; + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new CsSigAnalyzer()), project.AnalyzerOptions); + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + return diagnostics.Where(d => d.Id == "CSSIG002").ToImmutableArray(); + } + + /// Registers fixes for the first CSSIG002 diagnostic and returns the offered actions + /// in registration order. + public async Task> RegisterFirstAsync() + { + var diagnostics = await MissingDiagnosticsAsync(); + var trigger = diagnostics.First(); + + var actions = ImmutableArray.CreateBuilder(); + var context = new CodeFixContext( + Project.GetDocument(SourceId)!, trigger, (a, _) => actions.Add(a), CancellationToken.None); + await new CsSigCodeFixProvider().RegisterCodeFixesAsync(context); + return actions.ToImmutable(); + } + + /// Invokes the provider's FixAll with the given equivalence key across the whole + /// project. + public async Task> ApplyFixAllAsync(string equivalenceKey) + { + var diagnostics = await MissingDiagnosticsAsync(); + var provider = new CsSigCodeFixProvider(); + var fixAllContext = new FixAllContext( + Project.GetDocument(SourceId)!, + provider, + FixAllScope.Project, + equivalenceKey, + provider.FixableDiagnosticIds, + new CollectedDiagnosticProvider(diagnostics), + CancellationToken.None); + + var action = await provider.GetFixAllProvider()!.GetFixAsync(fixAllContext); + return await ApplyAsync(action!, ProjectId); + } + + /// Applies a code action and returns the resulting .cssig documents as a + /// name -> text map. + public static async Task> ApplyAsync(CodeAction action, ProjectId projectId) + { + var operations = await action.GetOperationsAsync(CancellationToken.None); + var changed = operations.OfType().Single().ChangedSolution; + + var result = new Dictionary(); + foreach (var doc in changed.GetProject(projectId)!.AdditionalDocuments) + { + result[doc.Name] = (await doc.GetTextAsync(CancellationToken.None)).ToString(); + } + + return result; + } + + private sealed class CollectedDiagnosticProvider : FixAllContext.DiagnosticProvider + { + private readonly ImmutableArray _diagnostics; + + public CollectedDiagnosticProvider(ImmutableArray diagnostics) => _diagnostics = diagnostics; + + public override Task> GetAllDiagnosticsAsync( + Project project, CancellationToken cancellationToken) + => Task.FromResult>(_diagnostics); + + public override Task> GetDocumentDiagnosticsAsync( + Document document, CancellationToken cancellationToken) + => Task.FromResult>(_diagnostics); + + public override Task> GetProjectDiagnosticsAsync( + Project project, CancellationToken cancellationToken) + => Task.FromResult>(_diagnostics); + } + } + + private static Task> RunAsync(string source, params string[] signatureFiles) + => RunCoreAsync(source, equivalence: null, nullable: false, signatureFiles); + + private static Task> RunNullableAsync(string source, params string[] signatureFiles) + => RunCoreAsync(source, equivalence: null, nullable: true, signatureFiles); + + private static Task> RunWithEquivalenceAsync( + string source, string? equivalence, params string[] signatureFiles) + => RunCoreAsync(source, equivalence, nullable: false, signatureFiles); + + private static Task> RunNullableWithEquivalenceAsync( + string source, string? equivalence, params string[] signatureFiles) + => RunCoreAsync(source, equivalence, nullable: true, signatureFiles); + + private static async Task> RunCoreAsync( + string source, string? equivalence, bool nullable, params string[] signatureFiles) + { + var references = await ReferenceAssemblies.Net.Net60.ResolveAsync(LanguageNames.CSharp, CancellationToken.None); + + var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true); + if (nullable) + { + compilationOptions = compilationOptions.WithNullableContextOptions(NullableContextOptions.Enable); + } + + var compilation = CSharpCompilation.Create( + "TestProject", + new[] { CSharpSyntaxTree.ParseText(source, path: "Test.cs") }, + references, + compilationOptions); + + var additionalFiles = ImmutableArray.CreateRange( + signatureFiles.Select((content, i) => (AdditionalText)new InMemoryAdditionalText($"Api{i}.cssig", content))); + + var options = equivalence is null + ? new AnalyzerOptions(additionalFiles) + : new AnalyzerOptions(additionalFiles, new TestConfigOptionsProvider( + ImmutableDictionary.Empty.Add("build_property.CsSigEquivalence", equivalence))); + + var withAnalyzers = compilation.WithAnalyzers( + ImmutableArray.Create(new CsSigAnalyzer()), + options); + + var diagnostics = await withAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None); + return diagnostics.OrderBy(d => d.Id).ToImmutableArray(); + } + + private sealed class TestConfigOptionsProvider : AnalyzerConfigOptionsProvider + { + private readonly AnalyzerConfigOptions _global; + + public TestConfigOptionsProvider(ImmutableDictionary globals) + => _global = new TestConfigOptions(globals); + + public override AnalyzerConfigOptions GlobalOptions => _global; + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestConfigOptions.Empty; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => TestConfigOptions.Empty; + } + + private sealed class TestConfigOptions : AnalyzerConfigOptions + { + public static readonly TestConfigOptions Empty = new(ImmutableDictionary.Empty); + + private readonly ImmutableDictionary _values; + + public TestConfigOptions(ImmutableDictionary values) => _values = values; + + public override bool TryGetValue( + string key, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) + { + if (_values.TryGetValue(key, out var v)) + { + value = v; + return true; + } + + value = null; + return false; + } + } + + private sealed class InMemoryAdditionalText : AdditionalText + { + private readonly SourceText _text; + + public InMemoryAdditionalText(string path, string text) + { + Path = path; + _text = SourceText.From(text); + } + + public override string Path { get; } + + public override SourceText GetText(CancellationToken cancellationToken = default) => _text; + } +} diff --git a/test/test/IndentingBuilderTests.cs b/test/test/IndentingBuilderTests.cs index c0d9da6..059486e 100644 --- a/test/test/IndentingBuilderTests.cs +++ b/test/test/IndentingBuilderTests.cs @@ -1,9 +1,15 @@ +extern alias IbLib; using System; using System.Text; using Xunit; namespace StaticCs.Tests; +// IndentingBuilder is also compiled into StaticCs.CsSig.Analyzers (it consumes the source-only +// StaticCS.IndentingBuilder package), so the simple name is ambiguous in this project. Bind it to +// the standalone IndentingBuilder library under test via the IbLib alias. +using IndentingBuilder = IbLib::StaticCs.IndentingBuilder; + public sealed class IndentingBuilderTests { [Fact] diff --git a/test/test/test.csproj b/test/test/test.csproj index eff403d..4b9fe94 100644 --- a/test/test/test.csproj +++ b/test/test/test.csproj @@ -1,35 +1,33 @@ - - - net8.0 - enable - false - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - + + + + net8.0 + enable + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + +