From 56cd0ae2deae243c6e74c1bc7279543d7a222311 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 2 Sep 2026 19:31:35 +0100 Subject: [PATCH 1/2] feat: added code query support for test framework --- package.json | 2 +- src/SourceGeneratorFramework.slnx | 2 + .../AnalyzerReleases.Unshipped.md | 1 + .../PreferNullableContextOverloadAnalyzer.cs | 67 ++++ ...rNullableContextOverloadCodeFixProvider.cs | 131 +++++++ .../LoggingRefactoringProvider.cs | 48 +++ ...amework.ExampleGenerator.CodeFixers.csproj | 12 + .../LogAttributeData.cs | 31 ++ .../LoggingAttributes.cs | 97 +++++ .../README.md | 60 ++++ .../ServiceRegistrationEmitter.cs | 2 +- .../Helpers/AttributeDataModelLibrary.cs | 3 +- .../Assertions/CodeQueryAssertions.cs | 211 +++++++++++ .../GeneratedCodeAssertionsExtensions.cs | 39 ++- .../CallerArgumentExpressionAttribute.cs | 19 + .../agents/test-author-writer.agent.md | 50 +++ ...odernize-test-to-codequery-tunit.prompt.md | 48 +++ .../skills/tunit-test-authoring/SKILL.md | 180 ++++++++++ .../Sdk/README.md | 60 ++++ ...rceGeneratorFramework.Testing.TUnit.csproj | 5 +- .../TUnitCodeFixTestBase.cs | 14 + .../TUnitRefactoringTestBase.cs | 27 ++ .../AnalyzerTestResult.cs | 11 +- .../CodeFixTestRunner.cs | 166 ++++++++- .../CodeQuery.Declarations.cs | 262 ++++++++++++++ .../CodeQuery.Signatures.cs | 151 ++++++++ .../CodeQuery.cs | 121 +++++++ .../CodeQueryResultExtensions.cs | 101 ++++++ .../DriverRunResult.cs | 2 +- .../CodeAnalysis/NotNullWhenAttribute.cs | 19 + .../IncrementalCacheResult.cs | 40 +++ .../MemberQueryExtensions.cs | 279 +++++++++++++++ .../RefactorTestOptions.cs | 26 ++ .../RefactorTestResult.cs | 17 + .../RefactoringTestRunner.cs | 84 +++++ .../RoslynTestRunner.cs | 7 +- .../skills/source-generator-testing/SKILL.md | 330 ++++++++++++++++++ .../Sdk/README.md | 65 ++++ .../SourceGeneratorFramework.Testing.csproj | 2 + .../SourceGeneratorHelpers.cs | 24 +- .../SourceGeneratorTestBase.cs | 44 +++ .../SourceGeneratorTestOptions.cs | 7 + .../SourceGeneratorTestRunner.cs | 159 ++++++++- .../SyntaxNotFoundException.cs | 16 + .../SourceGeneratorFramework/Sdk/README.md | 141 ++++++++ src/src/SourceGeneratorShared/CodeWriter.cs | 96 +++-- .../Extensions/System/StringExtension.cs | 17 - .../GenerationSettings.cs | 14 + .../Helpers/IncrementalPipeline.cs | 27 +- .../NullableDirectiveMode.cs | 20 ++ src/src/SourceGeneratorShared/TypeIdentity.cs | 97 ++++- src/src/SourceGeneratorShared/TypeModifier.cs | 78 ++++- .../SourceGeneratorShared/TypeReference.cs | 278 +++++++++++++-- .../SourceGeneratorShared/XmlCommentWriter.cs | 202 ++++++++++- ...ferNullableContextOverloadAnalyzerTests.cs | 109 ++++++ ...ableContextOverloadCodeFixProviderTests.cs | 120 +++++++ .../LoggingRefactoringTests.cs | 68 ++++ ...ampleGenerator.CodeFixers.UnitTests.csproj | 9 + .../LogAttributeDataTests.cs | 94 +++++ .../ServiceRegistrationCacheTests.cs | 125 +++++++ .../ServiceRegistrationGeneratorTests.cs | 190 ++++++++-- ...ramework.ExampleGenerator.UnitTests.csproj | 1 + .../AttributeDataModelGeneratorTests.cs | 39 +++ .../AddObsoleteRefactoringProvider.cs | 46 +++ .../CodeQueryAssertionTests.cs | 82 +++++ .../CodeQueryTests.cs | 207 +++++++++++ .../MemberQueryTests.cs | 111 ++++++ .../RefactoringTests.cs | 81 +++++ .../SourceGeneratorFramework.UnitTests.csproj | 3 + .../CodeWriterTests.cs | 164 +++++++++ .../GenerationContextTests.cs | 17 + .../IncrementalPipelineCacheTests.cs | 147 ++++++++ .../TypeReferenceTests.cs | 208 +++++++++++ .../XmlCommentWriterTests.cs | 148 ++++++++ 74 files changed, 5797 insertions(+), 184 deletions(-) create mode 100644 src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/PreferNullableContextOverloadCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/SourceGeneratorFramework.ExampleGenerator.CodeFixers.csproj create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs create mode 100644 src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Extensions/System/Runtime/CompilerServices/CallerArgumentExpressionAttribute.cs create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/agents/test-author-writer.agent.md create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/skills/tunit-test-authoring/SKILL.md create mode 100644 src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/CodeQuery.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/Extensions/System/Diagnostics/CodeAnalysis/NotNullWhenAttribute.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/IncrementalCacheResult.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs create mode 100644 src/src/SourceGeneratorFramework.Testing/Sdk/.agents/skills/source-generator-testing/SKILL.md create mode 100644 src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs delete mode 100644 src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs create mode 100644 src/src/SourceGeneratorShared/NullableDirectiveMode.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferNullableContextOverloadAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferNullableContextOverloadCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/LoggingRefactoringTests.cs create mode 100644 src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests.csproj create mode 100644 src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs create mode 100644 src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationCacheTests.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/AddObsoleteRefactoringProvider.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/CodeQueryAssertionTests.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/MemberQueryTests.cs create mode 100644 src/tests/SourceGeneratorFramework.UnitTests/RefactoringTests.cs create mode 100644 src/tests/SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests.cs diff --git a/package.json b/package.json index 68bdd48..a1db47d 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.27", + "version": "1.0.0-prerelease.28", "private": true } diff --git a/src/SourceGeneratorFramework.slnx b/src/SourceGeneratorFramework.slnx index 3889899..64baabb 100644 --- a/src/SourceGeneratorFramework.slnx +++ b/src/SourceGeneratorFramework.slnx @@ -22,6 +22,7 @@ + @@ -36,6 +37,7 @@ + diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index a8b7f1c..ba3020d 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -6,4 +6,5 @@ PSGFR11 | Purview.SourceGeneratorFramework | Warning | Prefer ForAttributeWithMe PSGFR12 | Purview.SourceGeneratorFramework | Warning | Use IIncrementalGenerator instead of ISourceGenerator PSGFR14 | Purview.SourceGeneratorFramework | Warning | Avoid RegisterImplementationSourceOutput PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection lacks sequence equality +PSGFR16 | Purview.SourceGeneratorFramework | Info | Prefer the nullable-context overload ADM0010 | Property | Error | Attribute data model property type is not cacheable | diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs new file mode 100644 index 0000000..449dd05 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferNullableContextOverloadAnalyzer.cs @@ -0,0 +1,67 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Suggests passing the GenerationSettings or CodeWriter to +/// Nullable()/MakeNullable() so a nullable annotation is emitted only when the target +/// compilation supports nullable. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PreferNullableContextOverloadAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR16"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Prefer the nullable-context overload", + "Pass GenerationSettings or CodeWriter to {0}() so the nullable annotation is emitted only when the target compilation supports it", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Use Nullable(GenerationSettings)/Nullable(CodeWriter) or MakeNullable(GenerationSettings)/MakeNullable(CodeWriter) when a generation context is available." + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + var invocation = (InvocationExpressionSyntax)context.Node; + if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess) + return; + + var methodName = memberAccess.Name.Identifier.Text; + if (methodName is not ("Nullable" or "MakeNullable")) + return; + + if (invocation.ArgumentList.Arguments.Count != 0) + return; + + var symbol = context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol; + if (symbol is not IMethodSymbol method) + return; + + var containingType = method.ContainingType?.ToDisplayString(); + if ( + containingType + is not ("Purview.SourceGeneratorFramework.TypeReference" or "Purview.SourceGeneratorFramework.TypeIdentity") + ) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), methodName)); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferNullableContextOverloadCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferNullableContextOverloadCodeFixProvider.cs new file mode 100644 index 0000000..6d0189c --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferNullableContextOverloadCodeFixProvider.cs @@ -0,0 +1,131 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Fixes a bare Nullable()/MakeNullable() call by passing the first in-scope +/// CodeWriter or GenerationSettings, so the annotation is emitted only when the target +/// compilation supports nullable. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferNullableContextOverloadCodeFixProvider))] +public sealed class PreferNullableContextOverloadCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "PassNullableContext"; + + const string CodeWriterTypeName = "Purview.SourceGeneratorFramework.CodeWriter"; + const string GenerationSettingsTypeName = "Purview.SourceGeneratorFramework.GenerationSettings"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR16"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + var semanticModel = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (root is null || semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not InvocationExpressionSyntax invocation) + continue; + + if (!TryFindNullableContext(semanticModel, invocation, out var argumentName, context.CancellationToken)) + continue; + + var methodName = ((MemberAccessExpressionSyntax)invocation.Expression).Name.Identifier.Text; + + context.RegisterCodeFix( + CodeAction.Create( + $"Pass '{argumentName}' to {methodName}()", + _ => AddArgumentAsync(context.Document, invocation, argumentName), + EquivalenceKey + ), + diagnostic + ); + } + } + + static async Task AddArgumentAsync( + Document document, + InvocationExpressionSyntax invocation, + string argumentName + ) + { + var root = (await document.GetSyntaxRootAsync().ConfigureAwait(false))!; + var newArgumentList = SyntaxFactory + .ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(SyntaxFactory.IdentifierName(argumentName))) + ) + .WithTriviaFrom(invocation.ArgumentList); + + return document.WithSyntaxRoot(root.ReplaceNode(invocation.ArgumentList, newArgumentList)); + } + + static bool TryFindNullableContext( + SemanticModel semanticModel, + SyntaxNode invocation, + out string argumentName, + CancellationToken cancellationToken + ) + { + argumentName = string.Empty; + + // Parameters of the enclosing method are always in scope. + if (semanticModel.GetEnclosingSymbol(invocation.SpanStart, cancellationToken) is IMethodSymbol method) + { + foreach (var parameter in method.Parameters) + { + if (IsNullableContextType(parameter.Type)) + { + argumentName = parameter.Name; + return true; + } + } + } + + // Walk the enclosing blocks from the invocation outward, collecting variables declared before it. + for ( + var block = invocation.FirstAncestorOrSelf(); + block is not null; + block = block.Parent?.FirstAncestorOrSelf() + ) + { + foreach (var variable in block.DescendantNodes().OfType()) + { + if (variable.Span.End >= invocation.SpanStart) + continue; + + if ( + semanticModel.GetDeclaredSymbol(variable, cancellationToken) is ILocalSymbol local + && IsNullableContextType(local.Type) + ) + { + argumentName = local.Name; + return true; + } + } + + if (block.Parent is BaseMethodDeclarationSyntax or LocalFunctionStatementSyntax) + break; + } + + return false; + } + + static bool IsNullableContextType(ITypeSymbol? type) + { + var name = type?.ToDisplayString(); + + return name is CodeWriterTypeName or GenerationSettingsTypeName; + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs new file mode 100644 index 0000000..bf1b9d0 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/LoggingRefactoringProvider.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.ExampleGenerator.CodeFixers; + +/// +/// A sample refactoring that adds a [Debug] attribute (from the logging sample's +/// DebugAttribute) to the method the cursor is on. +/// +[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(LoggingRefactoringProvider))] +public sealed class LoggingRefactoringProvider : CodeRefactoringProvider +{ + /// The equivalence key of the registered code action. + public const string EquivalenceKey = "AddDebug"; + + public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + var method = root?.FindNode(context.Span).FirstAncestorOrSelf(); + if (method is null) + return; + + context.RegisterRefactoring( + CodeAction.Create( + "Add [Debug]", + cancellationToken => AddDebugAttributeAsync(context.Document, method, cancellationToken), + EquivalenceKey + ) + ); + } + + static async Task AddDebugAttributeAsync( + Document document, + MethodDeclarationSyntax method, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var attribute = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Attribute(SyntaxFactory.ParseName("Debug"))) + ); + + return document.WithSyntaxRoot(root!.ReplaceNode(method, method.AddAttributeLists(attribute))); + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/SourceGeneratorFramework.ExampleGenerator.CodeFixers.csproj b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/SourceGeneratorFramework.ExampleGenerator.CodeFixers.csproj new file mode 100644 index 0000000..12b27ff --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator.CodeFixers/SourceGeneratorFramework.ExampleGenerator.CodeFixers.csproj @@ -0,0 +1,12 @@ + + + false + true + + + + + + + + diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs new file mode 100644 index 0000000..e457886 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LogAttributeData.cs @@ -0,0 +1,31 @@ +namespace Purview.SourceGeneratorFramework.Examples; + +/// +/// Attribute data model for . +/// +/// +/// MatchByInheritance = true makes this model accept derived attribute types too, so it can read a +/// application through the same extraction logic. +/// +[Generate(typeof(LogAttribute), MatchByInheritance = true)] +public readonly partial record struct LogAttributeData( + [Property] string? Message, + [Property] int EventId, + [Property] string? CategoryName, + [Property(DefaultValue = LogLevel.Information)] LogLevel Level +); + +/// +/// Attribute data model for . +/// +/// +/// Reuses the mapping for the inherited properties via [NestedModel] and +/// overrides to default to . Roslyn's +/// AttributeData does not surface values assigned inside the attribute's constructor body, so the +/// "Debug through inheritance" default is declared here on the model. +/// +[Generate(typeof(DebugAttribute))] +public readonly partial record struct DebugAttributeData( + [NestedModel] LogAttributeData Log, + [Property(DefaultValue = LogLevel.Debug)] LogLevel Level +); diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs new file mode 100644 index 0000000..87eb4a6 --- /dev/null +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/LoggingAttributes.cs @@ -0,0 +1,97 @@ +namespace Purview.SourceGeneratorFramework.Examples; + +/// +/// Defines the severity of a log entry. +/// +public enum LogLevel +{ + /// Trace-level detail. + Trace = 0, + + /// Debug-level detail. + Debug = 1, + + /// Informational messages. + Information = 2, + + /// Warnings. + Warning = 3, + + /// Errors. + Error = 4, + + /// Critical failures. + Critical = 5, +} + +/// +/// Marks a type or member as a candidate for log emission. +/// +/// +/// The derived demonstrates attribute inheritance: it shares every property +/// declared here while pinning to . +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1813:Avoid unsealed attributes")] +public class LogAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public LogAttribute() { } + + /// + /// Initializes a new instance of the class with a message. + /// + /// The log message template. + public LogAttribute(string message) + { + Message = message; + } + + /// + /// Gets or sets the log message template. + /// + public string? Message { get; private set; } + + /// + /// Gets or sets the event identifier. + /// + public int EventId { get; init; } + + /// + /// Gets or sets the log category name. + /// + public string? CategoryName { get; init; } + + /// + /// Gets or sets the log level. + /// + public LogLevel Level { get; init; } = LogLevel.Information; +} + +/// +/// A that defaults to +/// while inheriting all other properties. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class DebugAttribute : LogAttribute +{ + /// + /// Initializes a new instance of the class. + /// + public DebugAttribute() + { + Level = LogLevel.Debug; + } + + /// + /// Initializes a new instance of the class with a message. + /// + /// The log message template. + public DebugAttribute(string message) + : base(message) + { + Level = LogLevel.Debug; + } +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md index b5c68eb..b0fde1f 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/README.md +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/README.md @@ -33,6 +33,66 @@ Reference implementation of an incremental C# source generator built with `Purvi - Generator disabling via MSBuild properties. - Unit testing with `Purview.SourceGeneratorFramework.Testing`. +## Inherited attribute mapping (Log → Debug) + +`LogAttribute` and its derived `DebugAttribute` demonstrate mapping an attribute hierarchy with `AttributeDataModelGenerator`. `DebugAttribute` inherits every property from `LogAttribute` but pins `Level` to `LogLevel.Debug`: + +```csharp +[Generate(typeof(LogAttribute), MatchByInheritance = true)] +public readonly partial record struct LogAttributeData( + [Property] string? Message, + [Property] int EventId, + [Property] string? CategoryName, + [Property(DefaultValue = LogLevel.Information)] LogLevel Level +); + +[Generate(typeof(DebugAttribute))] +public readonly partial record struct DebugAttributeData( + [NestedModel] LogAttributeData Log, + [Property(DefaultValue = LogLevel.Debug)] LogLevel Level +); +``` + +- `MatchByInheritance = true` makes `LogAttributeData` accept `[Debug]` applications too. +- `[NestedModel]` reuses the parent's full mapping without duplicating the property list. +- `[Property(DefaultValue = ...)]` supplies the fallback. Roslyn's `AttributeData` does not surface values assigned inside the attribute constructor body, so the "Debug through inheritance" default is declared on the model rather than relied on from `DebugAttribute`'s constructor. + +Tests are in [`LogAttributeDataTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.UnitTests). + +## Refactoring sample (Logging) + +`LoggingRefactoringProvider` lives in the separate +[`SourceGeneratorFramework.ExampleGenerator.CodeFixers`](../SourceGeneratorFramework.ExampleGenerator.CodeFixers) +assembly — Roslyn requires compiler extensions (generators) and code fix/refactoring providers to be in +separate assemblies, because generators must not reference `Microsoft.CodeAnalysis.Workspaces` (RS1038). It +adds a `[Debug]` attribute (from the logging sample) to the method under the cursor, and demonstrates the +refactoring test infrastructure and the `CodeQuery` API for locating the trigger node and asserting on the +refactored output: + +```csharp +var result = await RefactorAsync( + source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("Process"), + EquivalenceKey = LoggingRefactoringProvider.EquivalenceKey, + }, + cancellationToken); + +var method = result.FixedCode().GetMethod("Process"); +await Assert.That(method.AttributeLists).IsNotEmpty(); +``` + +Tests are in [`LoggingRefactoringTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests). + +## Testing + +Tests in [`SourceGeneratorFramework.ExampleGenerator.UnitTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.UnitTests) exercise the `CodeQuery` syntax-lookup API and the TUnit assertion extensions (`HasGeneratedMethod`, `HasGeneratedClass`, `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`) in addition to the raw generated-text assertions. + +### Incremental cache tests + +[`ServiceRegistrationCacheTests`](../../tests/SourceGeneratorFramework.ExampleGenerator.UnitTests) proves the pipeline caches correctly stage-by-stage using `GenerateIncrementalAsync`: the first run reports every framework stage as `New`, an identical rerun keeps them `Cached`/`Unchanged`, and a property-only or source-only change marks only the affected stage `Modified`. Other generator projects should mirror this pattern with `RunIncrementalAsync`/`GenerateIncrementalAsync`. + ## Usage ```csharp diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs index 39a9905..f87a6cf 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs @@ -51,7 +51,7 @@ public static void EmitAttributeAndEnum(IncrementalGeneratorPostInitializationCo cw.WriteProperty(new("Lifetime", TypeLibrary.ServiceLifetime, TypeDeclarationAccessibility.Public)); cw.WriteProperty( - new("Name", PurviewTypeLibrary.System.String.MakeNullable(), TypeDeclarationAccessibility.Public) + new("Name", PurviewTypeLibrary.System.String.MakeNullable(cw), TypeDeclarationAccessibility.Public) { HasSetter = true, Initializer = "null", diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs index 1c3076f..66febce 100644 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs @@ -689,8 +689,9 @@ static bool TryFormatValue(object? value, ITypeSymbol typeSymbol, out string exp if (typeSymbol.TypeKind == TypeKind.Enum) { + // ToFullyQualifiedDisplayString already includes the global:: prefix. var enumTypeName = TypeHelpers.ToFullyQualifiedDisplayString(typeSymbol); - expression = $"(global::{enumTypeName}){Convert.ToString(value, CultureInfo.InvariantCulture)}"; + expression = $"({enumTypeName}){Convert.ToString(value, CultureInfo.InvariantCulture)}"; return true; } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs new file mode 100644 index 0000000..146a602 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/CodeQueryAssertions.cs @@ -0,0 +1,211 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using TUnit.Assertions.Attributes; +using TUnit.Assertions.Core; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +/// +/// TUnit assertion extensions that query the code produced by a test run and return the matched syntax node. +/// +public static partial class CodeQueryAssertions +{ + // --------------------------------------------------------------------------------------------- + // Generated code (source generators) + // --------------------------------------------------------------------------------------------- + + /// Asserts that the generated code contains a method with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedMethod( + this DriverRunResult result, + string methodName + ) => GetMethod(result?.Generated(), methodName, null, "generated code"); + + /// Asserts that the generated code contains a method with the given name and parameter types, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedMethod( + this DriverRunResult result, + string methodName, + TypeReference[] parameters + ) => GetMethod(result?.Generated(), methodName, parameters, "generated code"); + + /// Asserts that the generated code contains a method with the given name and return type, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedMethodReturnType( + this DriverRunResult result, + string methodName, + TypeReference returnType + ) + { + var query = result?.Generated(); + if (query is null) + return (AssertionResult)AssertionResult.Failed("expected DriverRunResult is null"); + if (string.IsNullOrWhiteSpace(methodName)) + return (AssertionResult) + AssertionResult.Failed("method name cannot be null or whitespace"); + + if (query.TryGetMethod(methodName, out var method) && query.HasReturnType(methodName, returnType)) + return AssertionResult.Passed(method!); + + // If the method exists but has a different return type, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed( + $"generated code did not contain a method named '{methodName}' with the expected return type" + ); + } + + /// Asserts that the generated code contains a class with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedClass( + this DriverRunResult result, + string className + ) + { + var query = result?.Generated(); + if (query is null) + return (AssertionResult)AssertionResult.Failed("expected DriverRunResult is null"); + if (string.IsNullOrWhiteSpace(className)) + return (AssertionResult) + AssertionResult.Failed("class name cannot be null or whitespace"); + + if (query.TryGetClass(className, out var declaration)) + return AssertionResult.Passed(declaration!); + + // If the class exists but has a different type, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed($"generated code did not contain a class named '{className}'"); + } + + /// Asserts that the generated code contains a property with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedProperty( + this DriverRunResult result, + string propertyName + ) + { + var query = result?.Generated(); + if (query is null) + return (AssertionResult) + AssertionResult.Failed("expected DriverRunResult is null"); + if (string.IsNullOrWhiteSpace(propertyName)) + return (AssertionResult) + AssertionResult.Failed("property name cannot be null or whitespace"); + + if (query.TryGetProperty(propertyName, out var declaration)) + return AssertionResult.Passed(declaration!); + + // If the property exists but has a different type, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed($"generated code did not contain a property named '{propertyName}'"); + } + + /// Asserts that the generated code contains a field with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedField( + this DriverRunResult result, + string fieldName + ) + { + var query = result?.Generated(); + if (query is null) + return (AssertionResult)AssertionResult.Failed("expected DriverRunResult is null"); + if (string.IsNullOrWhiteSpace(fieldName)) + return (AssertionResult) + AssertionResult.Failed("field name cannot be null or whitespace"); + + if (query.TryGetField(fieldName, out var declaration)) + return AssertionResult.Passed(declaration!); + + // If the field exists but has a different type, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed($"generated code did not contain a field named '{fieldName}'"); + } + + /// Asserts that the generated code contains a syntax tree with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasGeneratedSyntaxTree(this DriverRunResult result, string treeName) + { + var query = result?.Generated(); + if (query is null) + return (AssertionResult)AssertionResult.Failed("expected DriverRunResult is null"); + if (string.IsNullOrWhiteSpace(treeName)) + return (AssertionResult)AssertionResult.Failed("tree name cannot be null or whitespace"); + + if (query.TryGetSyntaxTree(treeName, out var tree)) + return AssertionResult.Passed(tree!); + + // If the syntax tree exists but has a different name, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed($"generated code did not contain a syntax tree named '{treeName}'"); + } + + // --------------------------------------------------------------------------------------------- + // Fixed code (code fixes and refactorings) + // --------------------------------------------------------------------------------------------- + + /// Asserts that the fixed code contains a method with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasFixedMethod( + this CodeFixTestResult result, + string methodName + ) => GetMethod(result?.FixedCode(), methodName, null, "fixed code"); + + /// Asserts that the fixed code contains a method with the given name and parameter types, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasFixedMethod( + this CodeFixTestResult result, + string methodName, + TypeReference[] parameters + ) => GetMethod(result?.FixedCode(), methodName, parameters, "fixed code"); + + /// Asserts that the fixed code contains a method with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasFixedMethod( + this CodeFixFixAllResult result, + string methodName + ) => GetMethod(result?.FixedCode(), methodName, null, "fixed code"); + + /// Asserts that the fixed code contains a method with the given name, returning it. + [GenerateAssertion] + [EditorBrowsable(EditorBrowsableState.Never)] + public static AssertionResult HasFixedMethod( + this RefactorTestResult result, + string methodName + ) => GetMethod(result?.FixedCode(), methodName, null, "refactored code"); + + // --------------------------------------------------------------------------------------------- + // Shared + // --------------------------------------------------------------------------------------------- + + static AssertionResult GetMethod( + CodeQuery? query, + string methodName, + TypeReference[]? parameters, + string scope + ) + { + if (query is null) + return (AssertionResult)AssertionResult.Failed("expected test result is null"); + if (string.IsNullOrWhiteSpace(methodName)) + return (AssertionResult) + AssertionResult.Failed("method name cannot be null or whitespace"); + + if (query.TryGetMethod(methodName, out var method, parameters)) + return AssertionResult.Passed(method!); + + // If the method exists but has different parameters, we could provide more detail in the failure message. + return (AssertionResult) + AssertionResult.Failed($"{scope} did not contain a method named '{methodName}'"); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/GeneratedCodeAssertionsExtensions.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/GeneratedCodeAssertionsExtensions.cs index 9dce3b5..df830c5 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/GeneratedCodeAssertionsExtensions.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Assertions/GeneratedCodeAssertionsExtensions.cs @@ -18,14 +18,8 @@ public static bool GeneratesCode(this string generatedCode, string expectedCode, if (flattenWhitespace) { - actualCode = actualCode - .ReplaceLineEndings("") - .Replace("\t", "", StringComparison.Ordinal) - .Replace(" ", "", StringComparison.Ordinal); - expectedCode = expectedCode - .ReplaceLineEndings("") - .Replace("\t", "", StringComparison.Ordinal) - .Replace(" ", "", StringComparison.Ordinal); + actualCode = FlattenWhitespace(actualCode); + expectedCode = FlattenWhitespace(expectedCode); } return actualCode.Trim() == expectedCode.Trim(); @@ -47,16 +41,27 @@ public static bool ContainsGeneratedCode( if (flattenWhitespace) { - actualCode = actualCode - .ReplaceLineEndings("") - .Replace("\t", "", StringComparison.Ordinal) - .Replace(" ", "", StringComparison.Ordinal); - expectedCode = expectedCode - .ReplaceLineEndings("") - .Replace("\t", "", StringComparison.Ordinal) - .Replace(" ", "", StringComparison.Ordinal); + actualCode = FlattenWhitespace(actualCode); + expectedCode = FlattenWhitespace(expectedCode); } - return actualCode.Contains(expectedCode, StringComparison.Ordinal); + return +#if NETSTANDARD2_0 + actualCode.IndexOf(expectedCode, StringComparison.Ordinal) >= 0; +#else + actualCode.Contains(expectedCode, StringComparison.Ordinal); +#endif } + + // netstandard2.0 lacks the StringComparison overloads for Replace/Contains. + static string FlattenWhitespace(string value) => +#if NETSTANDARD2_0 + value.Replace("\r", "").Replace("\n", "").Replace("\t", "").Replace(" ", ""); +#else + value + .Replace("\r", "", StringComparison.Ordinal) + .Replace("\n", "", StringComparison.Ordinal) + .Replace("\t", "", StringComparison.Ordinal) + .Replace(" ", "", StringComparison.Ordinal); +#endif } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Extensions/System/Runtime/CompilerServices/CallerArgumentExpressionAttribute.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/Extensions/System/Runtime/CompilerServices/CallerArgumentExpressionAttribute.cs new file mode 100644 index 0000000..dcce53b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Extensions/System/Runtime/CompilerServices/CallerArgumentExpressionAttribute.cs @@ -0,0 +1,19 @@ +#if NETSTANDARD2_0 + +using System.ComponentModel; + +// netstandard2.0 declares CallerArgumentExpressionAttribute as internal, so TUnit's generated +// assertion code cannot reference it when targeting netstandard2.0. +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace System.Runtime.CompilerServices; + +#pragma warning restore IDE0130 + +[EditorBrowsable(EditorBrowsableState.Never)] +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +sealed class CallerArgumentExpressionAttribute(string parameterName) : Attribute +{ + public string ParameterName { get; } = parameterName; +} + +#endif diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/agents/test-author-writer.agent.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/agents/test-author-writer.agent.md new file mode 100644 index 0000000..fb5fb87 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/agents/test-author-writer.agent.md @@ -0,0 +1,50 @@ +--- +name: Test Author Writer +description: "Specialist for Purview.SourceGeneratorFramework test suites — writing, fixing, and modernising TUnit tests for generators, diagnostic analyzers, code fixes, and refactorings, and for adding stage-by-stage incremental cache tests." +tools: + [ + "search/codebase", + "edit/editFiles", + "search", + "execute/getTerminalOutput", + "execute/runInTerminal", + "read/terminalLastCommand", + "read/terminalSelection", + "execute/createAndRunTask", + "execute/runTask", + "read/getTaskOutput", + "vscodeTasks/createAndRunTask", + "vscodeTasks/getTaskOutput", + "vscodeTasks/runTask", + ] +--- + +You are a specialist for `Purview.SourceGeneratorFramework` test authoring. + +## Primary objective + +Produce correct, maintainable TUnit tests for source generators, diagnostic analyzers, code fix +providers, and refactoring providers, and prove incremental pipelines cache correctly. + +## Background knowledge + +Before writing or changing any test, load and apply the `source-generator-testing` skill (runner layer, +result types, `CodeQuery`, options, cache testing) and the `tunit-test-authoring` skill (base classes, +methods, assertion extensions, modernisation checklist). For source-generator emission work, also load the +`source-generator-codewriter-modernization` skill. + +Key rules: + +- Pick the base class by the Roslyn component type: generator → `TUnitSourceGeneratorTestBase` + + `GenerateAsync`; analyzer → `TUnitDiagnosticAnalyzerTestBase` + `AnalyzeAsync`; code fix → + `TUnitCodeFixTestBase` + `ApplyCodeFixAsync`/`ApplyFixAllAsync`; refactor → + `TUnitRefactoringTestBase` + `RefactorAsync`. +- Prefer `CodeQuery` (`result.Generated()` / `result.FixedCode()` with `Get/Has/TryGet`) over + raw-string assertions. +- Prefer the terminal assertion extensions (`HasGeneratedMethod`, `HasGeneratedClass`, …) that return + syntax nodes. +- Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies. +- For incremental pipelines, add a stage-by-stage cache test with `RunIncrementalAsync` / + `GenerateIncrementalAsync`, asserting `New` on first run and `Cached`/`Unchanged` on an identical rerun, + and `Modified` only on the stages whose inputs changed. +- Keep generated-output assertions deterministic (no timestamps); enable CodeWriter scope validation. \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md new file mode 100644 index 0000000..cf463ce --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/prompts/modernize-test-to-codequery-tunit.prompt.md @@ -0,0 +1,48 @@ +--- +agent: ask +description: "Modernise a Roslyn test suite to use CodeQuery + TUnit assertion extensions, and add a stage-by-stage incremental cache test." +--- + +You are modernising tests in this repository. Apply the guidance from the `source-generator-testing` and +`tunit-test-authoring` skills for picking the right base class, querying generated code with `CodeQuery`, +and asserting incremental caching. + +## Inputs + +- Target test file(s): `${input:targetFiles:Path(s) to test file(s)}` +- Roslyn component under test: `${input:componentType:generator|analyzer|codefix|refactor}` (inferred if blank) +- Generator/analyzer/code-fix/refactor type name: `${input:componentName:Component type name}` + +## Task + +Modernise each test so it uses the framework's `CodeQuery` syntax-lookup API and the TUnit assertion +extensions, and add a stage-by-stage cache test proving each incremental pipeline layer caches correctly. + +### Requirements + +1. Choose the correct base class and method for the component type: + - Generator → `TUnitSourceGeneratorTestBase` → `GenerateAsync`. + - Analyzer → `TUnitDiagnosticAnalyzerTestBase` → `AnalyzeAsync`. + - Code fix → `TUnitCodeFixTestBase` → `ApplyCodeFixAsync` / `ApplyFixAllAsync`. + - Refactor → `TUnitRefactoringTestBase` → `RefactorAsync`. +2. Replace `GetGeneratedTree(...)` + `string.Contains(...)` assertions with `CodeQuery` + (`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions + (`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)`) that return the node. +3. Replace signature string checks with `TypeReference` parameter/return-type matching. +4. Ensure options come from a derived `SourceGeneratorTestOptions` record seeding the required namespaces + and additional assemblies; remove per-test duplication. +5. Add an incremental cache test using `RunIncrementalAsync` (or `GenerateIncrementalAsync` on the TUnit + base) with the four scenarios from the skills' "Incremental cache testing" sections + (`ServiceRegistrationCacheTests` / `IncrementalPipelineCacheTests` are the reference pattern): + - first run → every framework stage `New`; + - identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) → framework stages + `Cached`/`Unchanged`; + - source-only change → `ForAttribute_*` `Modified`, property/config stages stay `Cached`; + - property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) → + `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/`GetGenerationContext_*` `Modified`, + `ForAttribute_*` stays `Cached`. + Use the `StepReasons(IncrementalCacheRun)` flattening helper; if the generator depends on its own + post-init output, assert on the framework-named stages rather than every tracked step. +6. Keep changes minimal and behavior equivalent; do not reformat unrelated tests. + +Verify by building the test project and running its suite before finishing. \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/skills/tunit-test-authoring/SKILL.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/skills/tunit-test-authoring/SKILL.md new file mode 100644 index 0000000..6c4ea2f --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/.agents/skills/tunit-test-authoring/SKILL.md @@ -0,0 +1,180 @@ +--- +name: tunit-test-authoring +description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests." +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`sdk` package's `source-generator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return syntax nodes: `HasGeneratedMethod` (optionally with + `TypeReference[]` parameter types), `HasGeneratedMethodReturnType`, `HasGeneratedClass`, + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. + ```csharp + MethodDeclarationSyntax method = await Assert.That(result).HasGeneratedMethod("DoWork", [intType, nullableInt]); + ClassDeclarationSyntax cls = await Assert.That(result).HasGeneratedClass("Service"); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `source-generator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `source-generator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `source-generator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `source-generator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md index 89b4e38..b057bb2 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/Sdk/README.md @@ -89,6 +89,66 @@ compatible Roslyn version (Roslyn 4.13 for a .NET 8–10 test matrix) and avoid `RegisterEmbeddedAttribute` helper can be used instead of Roslyn 4.14's `AddEmbeddedAttributeDefinition` API when .NET 8 compatibility is required. +## Which base class and method + +| Roslyn type | Base class | Method | +|---|---|---| +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| Diagnostic analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| Code fix (single) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| Code fix (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| Refactoring | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +## Easy starting point: derived options + +Derive a `SourceGeneratorTestOptions` record that seeds namespaces and additional assemblies, then pass it +to every test: + +```csharp +public sealed record MyTestOptions : SourceGeneratorTestOptions +{ + public MyTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(typeof(SomeDependencyType), typeof(TypeIdentity)); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase { ... } +``` + +Use `options.Compile()` for `CompileToAssembly`, and the `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` +hooks for per-run customisation. Code-fix/refactoring tests select actions with `EquivalenceKey` or +`CodeActionIndex` (and `RefactorTestOptions.NodeSelector`/`Span`). + +## Assertion extensions + +All assertion extensions are under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` (globally +imported). `await Assert.That(...)` is terminal and returns the value: + +- `HasGeneratedMethod` / `HasGeneratedMethodReturnType` / `HasGeneratedClass` / `HasGeneratedProperty` / + `HasGeneratedField` / `HasGeneratedSyntaxTree` — return the syntax node; `HasGeneratedMethod(name, TypeReference[])` + matches parameter types. +- `HasFixedMethod` — same for code-fix and refactoring results. +- `HasDiagnostic` / `HasDiagnostics` / `HasNoDiagnostics` / `DoesNotHaveDiagnostic` / `HasNoErrorDiagnostics`. +- `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- `GeneratesCode(expected)` / `ContainsGeneratedCode(expected)` (whitespace-flattened). + +```csharp +MethodDeclarationSyntax method = await Assert.That(result).HasGeneratedMethod("DoWork", [intType, nullableInt]); +await Assert.That(result).HasGeneratedSyntaxTree("Service.g.cs"); +``` + +## Incremental cache tests + +`GenerateIncrementalAsync` proves the pipeline caches stage-by-stage (first run `New`, identical rerun +`Cached`/`Unchanged`, targeted changes mark only the affected stage `Modified`). A reference +implementation (`ServiceRegistrationCacheTests`) lives in the `Purview.SourceGeneratorFramework` source +repository's example generator tests; replicate it in your own project with your own stage names. + ## License This project is licensed under the MIT license. diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj b/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj index bc1e7da..535b07f 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/SourceGeneratorFramework.Testing.TUnit.csproj @@ -1,9 +1,12 @@  $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared - $(TestingTargetFrameworks) + netstandard2.0;$(TestingTargetFrameworks) true $(RootNamespace) + + false diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs index 36f951b..85ecc28 100644 --- a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitCodeFixTestBase.cs @@ -32,4 +32,18 @@ protected Task ApplyCodeFixAsync( TOptions options, CancellationToken cancellationToken = default ) => _runner.RunAsync(source, options ?? new(), cancellationToken); + + /// Runs the analyzer and applies the code fix to every diagnostic in the project. + protected Task ApplyFixAllAsync( + IEnumerable sources, + TOptions? options = null, + CancellationToken cancellationToken = default + ) => _runner.RunFixAllAsync(sources, options ?? new(), cancellationToken); + + /// Runs the analyzer and applies the code fix to every diagnostic in the project. + protected Task ApplyFixAllAsync( + string source, + TOptions? options = null, + CancellationToken cancellationToken = default + ) => _runner.RunFixAllAsync([source], options ?? new(), cancellationToken); } diff --git a/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs new file mode 100644 index 0000000..d915801 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing.TUnit/TUnitRefactoringTestBase.cs @@ -0,0 +1,27 @@ +using Microsoft.CodeAnalysis.CodeRefactorings; + +namespace Purview.SourceGeneratorFramework.Testing.TUnit; + +/// TUnit-specific base class for refactoring tests. +public abstract class TUnitRefactoringTestBase + : TUnitRefactoringTestBase + where TRefactoring : CodeRefactoringProvider, new(); + +/// TUnit-specific base class for refactoring tests. +public abstract class TUnitRefactoringTestBase + where TRefactoring : CodeRefactoringProvider, new() + where TOptions : RefactorTestOptions, new() +{ + readonly RefactoringTestRunner _runner = new(); + + /// Runs the refactoring against the supplied source. + protected Task RefactorAsync(string source, CancellationToken cancellationToken = default) => + RefactorAsync(source, null!, cancellationToken); + + /// Runs the refactoring against the supplied source using the supplied options. + protected Task RefactorAsync( + string source, + TOptions options, + CancellationToken cancellationToken = default + ) => _runner.RunAsync(source, options ?? new(), cancellationToken); +} diff --git a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs index 2b861aa..0113df3 100644 --- a/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/AnalyzerTestResult.cs @@ -12,5 +12,14 @@ public sealed record CodeFixTestResult( ImmutableArray Diagnostics, ImmutableArray CodeActions, string FixedSource, - Compilation Compilation + Compilation Compilation, + Solution? ChangedSolution = null +); + +/// The result of a fix-all code fix test run. +public sealed record CodeFixFixAllResult( + ImmutableArray Diagnostics, + ImmutableArray CodeActions, + ImmutableDictionary FixedSources, + Solution ChangedSolution ); diff --git a/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs index 8ff8b09..d0e78ca 100644 --- a/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/CodeFixTestRunner.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; using Microsoft.CodeAnalysis.Diagnostics; @@ -27,14 +29,8 @@ await testProject.Project.GetCompilationAsync(cancellationToken) diagnostics.FirstOrDefault(diagnostic => diagnostic.Location.IsInSource) ?? throw new InvalidOperationException("The analyzer did not report a source diagnostic."); var document = testProject.Project.Solution.GetDocument(testProject.DocumentIds[0])!; - List actions = []; - var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); - await new TCodeFix().RegisterCodeFixesAsync(context); - - var action = options.EquivalenceKey is null - ? actions.ElementAtOrDefault(options.CodeActionIndex) - : actions.FirstOrDefault(action => action.EquivalenceKey == options.EquivalenceKey); - action = action ?? throw new InvalidOperationException("The requested code action was not registered."); + var actions = RegisterActions(document, diagnostic, cancellationToken); + var action = SelectAction(actions, options); var operations = await action.GetOperationsAsync(cancellationToken); var changedSolution = @@ -45,6 +41,158 @@ await testProject.Project.GetCompilationAsync(cancellationToken) ?? throw new InvalidOperationException("The code action removed the source document."); var fixedSource = (await changedDocument.GetTextAsync(cancellationToken)).ToString(); - return new(diagnostics, [.. actions], fixedSource, compilation); + return new(diagnostics, [.. actions], fixedSource, compilation, changedSolution); + } + + /// + /// Runs the analyzer and applies the registered code action to every diagnostic in the project using the + /// code fix's , then returns each document's fixed source. + /// + public async Task RunFixAllAsync( + IEnumerable sources, + CodeFixTestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new(); + using var testProject = CreateProject(sources, options, typeof(TAnalyzer).Assembly); + var compilation = + await testProject.Project.GetCompilationAsync(cancellationToken) + ?? throw new InvalidOperationException("Unable to create the test compilation."); + var diagnostics = await WithAnalyzers(compilation, [new TAnalyzer()], options) + .GetAnalyzerDiagnosticsAsync(cancellationToken); + var sourceDiagnostics = diagnostics.Where(diagnostic => diagnostic.Location.IsInSource).ToImmutableArray(); + if (sourceDiagnostics.IsEmpty) + throw new InvalidOperationException("The analyzer did not report a source diagnostic."); + + var firstDiagnostic = sourceDiagnostics[0]; + var document = + await GetDocumentForDiagnosticAsync(testProject, firstDiagnostic, cancellationToken) + ?? throw new InvalidOperationException("Unable to locate the document containing the diagnostic."); + var actions = RegisterActions(document, firstDiagnostic, cancellationToken); + var action = SelectAction(actions, options); + + var codeFixProvider = new TCodeFix(); + var fixAllAction = + await RunFixAllProviderAsync(document, codeFixProvider, action, sourceDiagnostics, cancellationToken) + ?? throw new InvalidOperationException("The FixAllProvider did not produce a code action."); + + var operations = await fixAllAction.GetOperationsAsync(cancellationToken); + var changedSolution = + operations.OfType().SingleOrDefault()?.ChangedSolution + ?? throw new InvalidOperationException("The fix-all action did not produce an ApplyChangesOperation."); + + var fixedSources = ImmutableDictionary.CreateBuilder(); + foreach (var documentId in testProject.DocumentIds) + { + var changedDocument = changedSolution.GetDocument(documentId); + if (changedDocument is null) + continue; + + fixedSources[changedDocument.Name] = (await changedDocument.GetTextAsync(cancellationToken)).ToString(); + } + + return new(diagnostics, [.. actions], fixedSources.ToImmutable(), changedSolution); + } + + static async Task RunFixAllProviderAsync( + Document document, + TCodeFix codeFixProvider, + CodeAction action, + ImmutableArray diagnostics, + CancellationToken cancellationToken + ) + { + var fixAllProvider = + codeFixProvider.GetFixAllProvider() + ?? throw new InvalidOperationException("The code fix provider has no FixAllProvider."); + + var fixAllContext = new FixAllContext( + document, + codeFixProvider, + FixAllScope.Project, + action.EquivalenceKey ?? string.Empty, + codeFixProvider.FixableDiagnosticIds, + new TestDiagnosticProvider(diagnostics), + cancellationToken + ); + + return await fixAllProvider.GetFixAsync(fixAllContext); + } + + static ImmutableArray RegisterActions( + Document document, + Diagnostic diagnostic, + CancellationToken cancellationToken + ) + { + List actions = []; + var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken); + new TCodeFix().RegisterCodeFixesAsync(context).GetAwaiter().GetResult(); + + return [.. actions]; + } + + static CodeAction SelectAction(ImmutableArray actions, CodeFixTestOptions options) + { + var action = options.EquivalenceKey is null + ? actions.ElementAtOrDefault(options.CodeActionIndex) + : actions.FirstOrDefault(action => action.EquivalenceKey == options.EquivalenceKey); + action = action ?? throw new InvalidOperationException("The requested code action was not registered."); + + return action; + } + + static async Task GetDocumentForDiagnosticAsync( + TestProject testProject, + Diagnostic diagnostic, + CancellationToken cancellationToken + ) + { + var tree = diagnostic.Location.SourceTree; + if (tree is null) + return null; + + foreach (var documentId in testProject.DocumentIds) + { + var document = testProject.Project.Solution.GetDocument(documentId); + if (document is null) + continue; + + var documentTree = await document.GetSyntaxTreeAsync(cancellationToken); + + if (documentTree == tree || string.Equals(documentTree?.FilePath, tree.FilePath, StringComparison.Ordinal)) + return document; + } + + return null; + } + + sealed class TestDiagnosticProvider(ImmutableArray diagnostics) : FixAllContext.DiagnosticProvider + { + public override async Task> GetDocumentDiagnosticsAsync( + Document document, + CancellationToken cancellationToken + ) + { + var tree = await document.GetSyntaxTreeAsync(cancellationToken); + + return diagnostics + .Where(diagnostic => + diagnostic.Location.SourceTree == tree + || string.Equals(diagnostic.Location.SourceTree?.FilePath, tree?.FilePath, StringComparison.Ordinal) + ) + .ToList(); + } + + public override Task> GetProjectDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) => Task.FromResult>([]); + + public override Task> GetAllDiagnosticsAsync( + Project project, + CancellationToken cancellationToken + ) => Task.FromResult>(diagnostics); } } diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs new file mode 100644 index 0000000..c4037bf --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Declarations.cs @@ -0,0 +1,262 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +public sealed partial class CodeQuery +{ + // --------------------------------------------------------------------------------------------- + // Methods + // --------------------------------------------------------------------------------------------- + + /// Gets a method declaration by name, optionally matching its parameter types. + /// No method matched. + public MethodDeclarationSyntax GetMethod(string name, params TypeReference[]? parameters) => + TryGetMethod(name, out var method, parameters) + ? method! + : throw new SyntaxNotFoundException( + $"No method named '{name}' was found in the {ScopeDescription()}{(parameters is { Length: > 0 } ? " with the specified parameters" : "")}." + ); + + /// Determines whether a method declaration with the given name, optionally matching parameter types, exists. + public bool HasMethod(string name, params TypeReference[]? parameters) => TryGetMethod(name, out _, parameters); + + /// Attempts to get a method declaration by name, optionally matching its parameter types. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public bool TryGetMethod(string name, out MethodDeclarationSyntax? method, params TypeReference[]? parameters) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The method name cannot be null or whitespace.", nameof(name)); + + var expected = parameters ?? []; + return TryFind( + name, + static candidate => candidate.Identifier.ValueText, + expected.Length == 0 ? null : candidate => HasParameters(candidate, expected), + out method + ); + } + + // --------------------------------------------------------------------------------------------- + // Type declarations + // --------------------------------------------------------------------------------------------- + + /// Gets a type declaration (class, struct, interface, record, enum or delegate) by name. + public MemberDeclarationSyntax GetTypeDeclaration(string name, string? @namespace = null) => + Get(node => IsTypeDeclarationMatch(node, name) && NamespaceMatches(node, @namespace)); + + /// Determines whether a type declaration with the given name exists. + public bool HasTypeDeclaration(string name, string? @namespace = null) => + Has(node => IsTypeDeclarationMatch(node, name) && NamespaceMatches(node, @namespace)); + + /// Gets a class declaration by name, optionally within a namespace. + public ClassDeclarationSyntax GetClass(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether a class declaration with the given name exists, optionally within a namespace. + public bool HasClass(string name, string? @namespace = null) => HasByName(name, @namespace); + + /// Attempts to get a class declaration by name, optionally within a namespace. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public bool TryGetClass(string name, out ClassDeclarationSyntax? declaration, string? @namespace = null) => + TryFindByName(name, out declaration, @namespace); + + /// Gets a struct declaration by name, optionally within a namespace. + public StructDeclarationSyntax GetStruct(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether a struct declaration with the given name exists, optionally within a namespace. + public bool HasStruct(string name, string? @namespace = null) => + HasByName(name, @namespace); + + /// Gets an interface declaration by name, optionally within a namespace. + public InterfaceDeclarationSyntax GetInterface(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether an interface declaration with the given name exists, optionally within a namespace. + public bool HasInterface(string name, string? @namespace = null) => + HasByName(name, @namespace); + + /// Gets an enum declaration by name, optionally within a namespace. + public EnumDeclarationSyntax GetEnum(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether an enum declaration with the given name exists, optionally within a namespace. + public bool HasEnum(string name, string? @namespace = null) => HasByName(name, @namespace); + + /// Gets a delegate declaration by name, optionally within a namespace. + public DelegateDeclarationSyntax GetDelegate(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether a delegate declaration with the given name exists, optionally within a namespace. + public bool HasDelegate(string name, string? @namespace = null) => + HasByName(name, @namespace); + + /// Gets a record declaration by name, optionally within a namespace. + public RecordDeclarationSyntax GetRecord(string name, string? @namespace = null) => + FindByName(name, @namespace); + + /// Determines whether a record declaration with the given name exists, optionally within a namespace. + public bool HasRecord(string name, string? @namespace = null) => + HasByName(name, @namespace); + + // --------------------------------------------------------------------------------------------- + // Members + // --------------------------------------------------------------------------------------------- + + /// Gets a property declaration by name. + public PropertyDeclarationSyntax GetProperty(string name) => + TryGetProperty(name, out var property) + ? property! + : throw new SyntaxNotFoundException($"No property named '{name}' was found in the {ScopeDescription()}."); + + /// Determines whether a property declaration with the given name exists. + public bool HasProperty(string name) => TryGetProperty(name, out _); + + /// Attempts to get a property declaration by name. + public bool TryGetProperty(string name, out PropertyDeclarationSyntax? property) => + TryFindByName(name, out property); + + /// Gets a field declaration by name. + /// Finds a by identifier and returns its declaring field. + public FieldDeclarationSyntax GetField(string name) => + TryGetField(name, out var field) + ? field! + : throw new SyntaxNotFoundException($"No field named '{name}' was found in the {ScopeDescription()}."); + + /// Determines whether a field declaration with the given name exists. + public bool HasField(string name) => TryGetField(name, out _); + + /// Attempts to get a field declaration by name. + public bool TryGetField(string name, out FieldDeclarationSyntax? field) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The field name cannot be null or whitespace.", nameof(name)); + + foreach (var tree in Trees) + { + foreach (var declarator in tree.GetRoot().DescendantNodes().OfType()) + { + if (declarator.Identifier.ValueText != name) + continue; + + if (declarator.Parent is not VariableDeclarationSyntax { Parent: FieldDeclarationSyntax candidate }) + continue; + + field = candidate; + return true; + } + } + + field = null; + return false; + } + + /// Gets a constructor declaration by the name of its containing type. + public ConstructorDeclarationSyntax GetConstructor(string containingTypeName) => + FindByName(containingTypeName); + + /// Determines whether a constructor declaration for the given containing type exists. + public bool HasConstructor(string containingTypeName) => + HasByName(containingTypeName); + + /// Gets a namespace declaration (block or file-scoped) by its dotted name. + public BaseNamespaceDeclarationSyntax GetNamespace(string name) => + FindByName( + name, + null, + namespaceDeclaration => namespaceDeclaration.Name.ToString() + ); + + /// Determines whether a namespace declaration with the given dotted name exists. + public bool HasNamespace(string name) => + HasByName( + name, + null, + namespaceDeclaration => namespaceDeclaration.Name.ToString() + ); + + // --------------------------------------------------------------------------------------------- + // Shared + // --------------------------------------------------------------------------------------------- + + static bool IsTypeDeclarationMatch(MemberDeclarationSyntax node, string name) => + node switch + { + ClassDeclarationSyntax @class => @class.Identifier.ValueText == name, + StructDeclarationSyntax @struct => @struct.Identifier.ValueText == name, + InterfaceDeclarationSyntax @interface => @interface.Identifier.ValueText == name, + EnumDeclarationSyntax @enum => @enum.Identifier.ValueText == name, + DelegateDeclarationSyntax @delegate => @delegate.Identifier.ValueText == name, + RecordDeclarationSyntax record => record.Identifier.ValueText == name, + _ => false, + }; + + T FindByName(string name, string? @namespace = null, Func? getName = null) + where T : SyntaxNode => + TryFindByName(name, out var node, @namespace, getName) + ? node! + : throw new SyntaxNotFoundException( + $"No {typeof(T).Name} named '{name}' was found in the {ScopeDescription()}{(string.IsNullOrEmpty(@namespace) ? "" : $" within namespace '{@namespace}'")}." + ); + + bool HasByName(string name, string? @namespace = null, Func? getName = null) + where T : SyntaxNode => TryFindByName(name, out _, @namespace, getName); + + bool TryFindByName(string name, out T? node, string? @namespace = null, Func? getName = null) + where T : SyntaxNode + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The name cannot be null or whitespace.", nameof(name)); + + // If a namespace is provided, we need to check that the node's declared namespace matches. + return TryFind( + name, + getName is null ? static candidate => GetIdentifier(candidate) : getName, + string.IsNullOrEmpty(@namespace) ? null : candidate => NamespaceMatches(candidate, @namespace), + out node + ); + } + + static bool NamespaceMatches(SyntaxNode node, string? @namespace) => + string.IsNullOrEmpty(@namespace) + || string.Equals(TypeSyntaxFacts.GetDeclaredNamespace(node), @namespace, StringComparison.Ordinal); + + bool TryFind(string name, Func getName, Func? additional, out T? node) + where T : SyntaxNode + { + foreach (var tree in Trees) + { + foreach (var candidate in tree.GetRoot().DescendantNodes().OfType()) + { + if (getName(candidate) != name) + continue; + + if (additional is not null && !additional(candidate)) + continue; + + node = candidate; + return true; + } + } + + node = null; + return false; + } + + static string GetIdentifier(T node) + where T : SyntaxNode => + node switch + { + MethodDeclarationSyntax method => method.Identifier.ValueText, + ClassDeclarationSyntax @class => @class.Identifier.ValueText, + StructDeclarationSyntax @struct => @struct.Identifier.ValueText, + InterfaceDeclarationSyntax @interface => @interface.Identifier.ValueText, + EnumDeclarationSyntax @enum => @enum.Identifier.ValueText, + DelegateDeclarationSyntax @delegate => @delegate.Identifier.ValueText, + RecordDeclarationSyntax record => record.Identifier.ValueText, + PropertyDeclarationSyntax property => property.Identifier.ValueText, + ConstructorDeclarationSyntax constructor => constructor.Identifier.ValueText, + _ => string.Empty, + }; +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs new file mode 100644 index 0000000..01b6f37 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.Signatures.cs @@ -0,0 +1,151 @@ +using System.ComponentModel; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +public sealed partial class CodeQuery +{ + /// + /// Determines whether a method or constructor declaration matches the given parameter types. + /// + /// + /// Parameter types are resolved through the query's and matched with the + /// framework's MatchesTypeReference semantics: nullable value types are significant + /// (int? does not match int) while nullable reference annotations are metadata. + /// + public bool HasParameters(BaseMethodDeclarationSyntax method, params TypeReference[] expected) + { + if (method is null) + throw new ArgumentNullException(nameof(method)); + if (expected is null) + throw new ArgumentNullException(nameof(expected)); + + var parameters = method.ParameterList.Parameters; + if (parameters.Count != expected.Length) + return false; + + for (var index = 0; index < parameters.Count; index++) + { + var typeSyntax = parameters[index].Type; + if (typeSyntax is null || !Matches(typeSyntax, expected[index])) + return false; + } + + return true; + } + + /// Determines whether a method declaration's return type matches the given reference. + public bool HasReturnType(string methodName, TypeReference returnType) + { + if (string.IsNullOrWhiteSpace(methodName)) + throw new ArgumentException("The method name cannot be null or whitespace.", nameof(methodName)); + if (returnType is null) + throw new ArgumentNullException(nameof(returnType)); + + if (!TryGetMethod(methodName, out var method)) + return false; + + // If the method has no return type (e.g., it's a constructor), it cannot match any reference. + return method!.ReturnType is { } returnTypeSyntax && Matches(returnTypeSyntax, returnType); + } + + /// + /// Determines whether a type syntax resolves to the given reference, using the query's compilation. + /// + public bool Matches(TypeSyntax typeSyntax, TypeReference reference) + { + if (typeSyntax is null) + throw new ArgumentNullException(nameof(typeSyntax)); + if (reference is null) + throw new ArgumentNullException(nameof(reference)); + + var compilation = Compilation; + if (compilation is null || !compilation.ContainsSyntaxTree(typeSyntax.SyntaxTree)) + { + throw new InvalidOperationException( + "The query's compilation does not contain the syntax tree being matched. Construct the query from the compilation's own trees (for example via the Generated/Output/FixedCode adapters) or use the syntactic Get/Has overloads." + ); + } + + // Delegate to the reference's MatchesTypeReference method, which handles symbol resolution and comparison. + return reference.MatchesTypeReference(typeSyntax, compilation.GetSemanticModel(typeSyntax.SyntaxTree)); + } +} + +/// +/// Signature inspection helpers for members obtained from a . These support chaining +/// from a member or type declaration, for example query.GetClass("C").HasMethod(query, "M", intType). +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class CodeQuerySignatureExtensions +{ + /// + /// Determines whether the method's or constructor's parameters match the given types, resolved through the + /// query's compilation. + /// + public static bool HasParameters( + this BaseMethodDeclarationSyntax method, + CodeQuery query, + params TypeReference[] expected + ) + { + if (method is null) + throw new ArgumentNullException(nameof(method)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + + // Delegate to the query's HasParameters method, which handles the parameter count and type matching. + return query.HasParameters(method, expected); + } + + /// + /// Determines whether the method's return type matches the given reference, resolved through the query's + /// compilation. + /// + public static bool HasReturnType(this MethodDeclarationSyntax method, CodeQuery query, TypeReference returnType) + { + if (method is null) + throw new ArgumentNullException(nameof(method)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (returnType is null) + throw new ArgumentNullException(nameof(returnType)); + + // If the method has no return type (e.g., it's a constructor), it cannot match any reference. + return method.ReturnType is { } returnTypeSyntax && query.Matches(returnTypeSyntax, returnType); + } + + /// + /// Determines whether the property's type matches the given reference, resolved through the query's + /// compilation. + /// + public static bool HasType(this PropertyDeclarationSyntax property, CodeQuery query, TypeReference propertyType) + { + if (property is null) + throw new ArgumentNullException(nameof(property)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (propertyType is null) + throw new ArgumentNullException(nameof(propertyType)); + + // The property type is always non-nullable in C# syntax, so we can directly match it with the expected type reference. + return query.Matches(property.Type, propertyType); + } + + /// + /// Determines whether the indexer's type matches the given reference, resolved through the query's + /// compilation. + /// + public static bool HasType(this IndexerDeclarationSyntax indexer, CodeQuery query, TypeReference indexerType) + { + if (indexer is null) + throw new ArgumentNullException(nameof(indexer)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (indexerType is null) + throw new ArgumentNullException(nameof(indexerType)); + + // The indexer type is always non-nullable in C# syntax, so we can directly match it with the expected type reference. + return query.Matches(indexer.Type, indexerType); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs b/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs new file mode 100644 index 0000000..698f5a2 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQuery.cs @@ -0,0 +1,121 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// A synchronous query over a set of syntax trees, optionally backed by a for +/// semantic resolution. Exposed by test results (see CodeQueryResultExtensions) so tests can locate a +/// syntax node — a method, class, property, and so on — and inspect it, including its parameter types. +/// +/// +/// Every operation is synchronous: SyntaxTree.GetRoot() and +/// Compilation.GetSemanticModel are lazy, so repeated queries over test-sized payloads are cheap. +/// +/// Initializes a new query over the given trees. +/// The trees to search. +/// +/// The compilation backing , used to resolve symbols for type matching. May be +/// when only syntactic matching is required. +/// +/// Whether the trees represent generated code, used in error messages. +public sealed partial class CodeQuery( + ImmutableArray trees, + Compilation? compilation = null, + bool isGenerated = false +) +{ + /// Gets the trees being searched. + public ImmutableArray Trees { get; } = trees.IsDefault ? [] : trees; + + /// Gets the compilation backing the trees, when one is available. + public Compilation? Compilation { get; } = compilation; + + /// Gets whether the trees represent generated code, used in error messages. + public bool IsGenerated { get; } = isGenerated; + + // --------------------------------------------------------------------------------------------- + // Syntax trees + // --------------------------------------------------------------------------------------------- + + /// Gets the tree whose file path ends with or equals the given name. + /// No tree matched. + public SyntaxTree GetSyntaxTree(string name) => + TryGetSyntaxTree(name, out var tree) + ? tree! + : throw new SyntaxNotFoundException( + $"No syntax tree named '{name}' was found in the {ScopeDescription()}." + ); + + /// Determines whether a tree whose file path ends with or equals the given name exists. + public bool HasSyntaxTree(string name) => TryGetSyntaxTree(name, out _); + + /// Attempts to get the tree whose file path ends with or equals the given name. + public bool TryGetSyntaxTree(string name, out SyntaxTree? tree) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The tree name cannot be null or whitespace.", nameof(name)); + + foreach (var candidate in Trees) + { + if ( + string.Equals(candidate.FilePath, name, StringComparison.Ordinal) + || candidate.FilePath.EndsWith(name, StringComparison.Ordinal) + ) + { + tree = candidate; + return true; + } + } + + tree = null; + return false; + } + + // --------------------------------------------------------------------------------------------- + // Generic + // --------------------------------------------------------------------------------------------- + + /// Gets the first syntax node of the specified type, optionally matching a predicate. + /// No node matched. + public T Get(Func? predicate = null) + where T : SyntaxNode => TryGet(out var node, predicate) ? node! : throw NotFound(); + + /// Determines whether a syntax node of the specified type exists, optionally matching a predicate. + public bool Has(Func? predicate = null) + where T : SyntaxNode => TryGet(out _, predicate); + + /// Attempts to get the first syntax node of the specified type, optionally matching a predicate. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public bool TryGet(out T? node, Func? predicate = null) + where T : SyntaxNode + { + foreach (var tree in Trees) + { + var root = tree.GetRoot(); + if (root is T rootNode && (predicate is null || predicate(rootNode))) + { + node = rootNode; + return true; + } + + foreach (var candidate in root.DescendantNodes().OfType()) + { + if (predicate is null || predicate(candidate)) + { + node = candidate; + return true; + } + } + } + + node = null; + return false; + } + + SyntaxNotFoundException NotFound() + where T : SyntaxNode => + new($"No syntax node of type '{typeof(T).Name}' was found in the {ScopeDescription()}."); + + string ScopeDescription() => IsGenerated ? "generated code" : "code"; +} diff --git a/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs b/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs new file mode 100644 index 0000000..80c01dd --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/CodeQueryResultExtensions.cs @@ -0,0 +1,101 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// Exposes instances over the code produced by each test result type. Queries default +/// to generated code first for source-generator runs (see Generated), with the full output available +/// via Output. +/// +public static class CodeQueryResultExtensions +{ + /// Gets a query over the generated trees of a source-generator run, with the output compilation. + public static CodeQuery Generated(this DriverRunResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var compilation = result.CompilationResult.Compilation; + return new(result.AllSyntaxTrees, compilation, isGenerated: true); + } + + /// Gets a query over the entire output compilation (user and generated trees) of a source-generator run. + public static CodeQuery Output(this DriverRunResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var compilation = result.CompilationResult.Compilation; + return new([.. compilation.SyntaxTrees], compilation); + } + + /// Gets a query over the trees of an analyzer test compilation. + public static CodeQuery Code(this AnalyzerTestResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + // Analyzer tests do not produce a changed solution, so the compilation is always the input compilation. + return new([.. result.Compilation.SyntaxTrees], result.Compilation); + } + + /// Gets a query over the input compilation of a code-fix test. + public static CodeQuery Code(this CodeFixTestResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + // Code-fix tests do not produce a changed solution, so the compilation is always the input compilation. + return new([.. result.Compilation.SyntaxTrees], result.Compilation); + } + + /// Gets a query over the fixed source produced by a code-fix test. + public static CodeQuery FixedCode(this CodeFixTestResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + if (result.ChangedSolution is { } solution) + { + var compilation = GetCompilation(solution); + + return compilation is null ? new([], null) : new([.. compilation.SyntaxTrees], compilation); + } + + return new(ParseSource(result.FixedSource), null); + } + + /// Gets a query over the fixed sources produced by a fix-all code-fix test. + public static CodeQuery FixedCode(this CodeFixFixAllResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var compilation = GetCompilation(result.ChangedSolution); + + return compilation is null ? new([], null) : new([.. compilation.SyntaxTrees], compilation); + } + + /// Gets a query over the refactored sources produced by a refactoring test. + public static CodeQuery FixedCode(this RefactorTestResult result) + { + if (result is null) + throw new ArgumentNullException(nameof(result)); + + var compilation = GetCompilation(result.ChangedSolution); + + return compilation is null ? new([], null) : new([.. compilation.SyntaxTrees], compilation); + } + + static ImmutableArray ParseSource(string source) => + [CSharpSyntaxTree.ParseText(source ?? string.Empty)]; + + static Compilation? GetCompilation(Solution solution) + { + var project = solution.Projects.FirstOrDefault(); + + return project?.GetCompilationAsync().GetAwaiter().GetResult(); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs index 82915f2..c257eb8 100644 --- a/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs +++ b/src/src/SourceGeneratorFramework.Testing/DriverRunResult.cs @@ -50,7 +50,7 @@ public void EnsureValid() .Where(d => d.Severity == DiagnosticSeverity.Error) .ToList(); var logErrors = LogEntries.Where(e => e.Type == SourceGenLogLevel.Fatal).ToList(); - var compilationErrorKeys = compilationErrors.Select(GetDiagnosticKey).ToHashSet(); + var compilationErrorKeys = new HashSet(compilationErrors.Select(GetDiagnosticKey)); var emitErrors = CompilationResult .Diagnostics.Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error) .Where(diagnostic => !compilationErrorKeys.Contains(GetDiagnosticKey(diagnostic))) diff --git a/src/src/SourceGeneratorFramework.Testing/Extensions/System/Diagnostics/CodeAnalysis/NotNullWhenAttribute.cs b/src/src/SourceGeneratorFramework.Testing/Extensions/System/Diagnostics/CodeAnalysis/NotNullWhenAttribute.cs new file mode 100644 index 0000000..afd63c5 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/Extensions/System/Diagnostics/CodeAnalysis/NotNullWhenAttribute.cs @@ -0,0 +1,19 @@ +#if NETSTANDARD2_0 + +using System.ComponentModel; + +// netstandard2.0 declares NotNullWhenAttribute as internal, which makes it unusable as a +// public/override attribute; a public definition is required when targeting netstandard2.0. +#pragma warning disable IDE0130 // Namespace does not match folder structure +namespace System.Diagnostics.CodeAnalysis; + +#pragma warning restore IDE0130 + +[EditorBrowsable(EditorBrowsableState.Never)] +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +public sealed class NotNullWhenAttribute(bool returnValue) : Attribute +{ + public bool ReturnValue { get; } = returnValue; +} + +#endif diff --git a/src/src/SourceGeneratorFramework.Testing/IncrementalCacheResult.cs b/src/src/SourceGeneratorFramework.Testing/IncrementalCacheResult.cs new file mode 100644 index 0000000..084de2c --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/IncrementalCacheResult.cs @@ -0,0 +1,40 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// The sources and optional analyzer-config overrides for a single run of an incremental cache test. +/// +/// The source files for this run. +/// +/// Optional analyzer-config (MSBuild property) overrides applied only for this run. Keys are used verbatim. +/// +public sealed record IncrementalRunInput( + IEnumerable Sources, + IEnumerable<(string Key, string Value)>? AnalyzerConfig = null +); + +/// +/// A single run of an incremental cache test: the generator run result and its tracked pipeline steps. +/// +/// The generator run result for this run. +/// +/// The tracked incremental steps keyed by their tracking name (for example +/// GetGenerationConfiguration, ForAttribute_MyAttribute). Each step's +/// IncrementalGeneratorRunStep.Outputs carry an IncrementalStepRunReason +/// (New, Modified, Cached, Unchanged) proving whether that pipeline stage was +/// recomputed or reused. +/// +public sealed record IncrementalCacheRun( + GeneratorRunResult RunResult, + ImmutableDictionary> Steps +); + +/// +/// The result of an incremental cache test run: one per input, produced by a +/// single shared so each run's step reasons reflect what changed since the +/// previous run. +/// +/// The per-run results, in input order. +public sealed record IncrementalCacheResult(ImmutableArray Runs); diff --git a/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs b/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs new file mode 100644 index 0000000..a79aa75 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/MemberQueryExtensions.cs @@ -0,0 +1,279 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// Member query extensions that chain from a type declaration obtained from a , for +/// example query.GetClass("Service").HasMethod(query, "Add", intType) or +/// query.GetRecord("Person").HasConstructor(query, stringType). +/// +public static class MemberQueryExtensions +{ + // --------------------------------------------------------------------------------------------- + // Properties + // --------------------------------------------------------------------------------------------- + + /// Gets a property declared on the type, optionally matching its type. + public static PropertyDeclarationSyntax GetProperty( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + TypeReference? propertyType = null + ) => + type.TryGetProperty(query, name, out var property, propertyType) + ? property! + : throw new SyntaxNotFoundException( + $"No property named '{name}' was found on '{type.Identifier.ValueText}'." + ); + + /// Determines whether the type declares a property with the given name, optionally matching its type. + public static bool HasProperty( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + TypeReference? propertyType = null + ) => type.TryGetProperty(query, name, out _, propertyType); + + /// Attempts to get a property declared on the type, optionally matching its type. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetProperty( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + out PropertyDeclarationSyntax? property, + TypeReference? propertyType = null + ) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The property name cannot be null or whitespace.", nameof(name)); + + foreach (var candidate in type.Members.OfType()) + { + if (candidate.Identifier.ValueText != name) + continue; + + if (propertyType is not null && !query.Matches(candidate.Type, propertyType)) + continue; + + property = candidate; + return true; + } + + property = null; + return false; + } + + // --------------------------------------------------------------------------------------------- + // Indexers + // --------------------------------------------------------------------------------------------- + + /// Gets an indexer declared on the type, optionally matching its type and index parameters. + public static IndexerDeclarationSyntax GetIndexer( + this TypeDeclarationSyntax type, + CodeQuery query, + TypeReference? indexerType = null, + params TypeReference[]? indexParameters + ) => + type.TryGetIndexer(query, out var indexer, indexerType, indexParameters) + ? indexer! + : throw new SyntaxNotFoundException($"No matching indexer was found on '{type.Identifier.ValueText}'."); + + /// Determines whether the type declares an indexer, optionally matching its type and index parameters. + public static bool HasIndexer( + this TypeDeclarationSyntax type, + CodeQuery query, + TypeReference? indexerType = null, + params TypeReference[]? indexParameters + ) => type.TryGetIndexer(query, out _, indexerType, indexParameters); + + /// Attempts to get an indexer declared on the type, optionally matching its type and index parameters. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetIndexer( + this TypeDeclarationSyntax type, + CodeQuery query, + out IndexerDeclarationSyntax? indexer, + TypeReference? indexerType = null, + params TypeReference[]? indexParameters + ) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + + var expected = indexParameters ?? []; + foreach (var candidate in type.Members.OfType()) + { + if (indexerType is not null && !query.Matches(candidate.Type, indexerType)) + continue; + + if (expected.Length > 0 && !IndexerParametersMatch(query, candidate, expected)) + continue; + + indexer = candidate; + return true; + } + + indexer = null; + return false; + } + + // --------------------------------------------------------------------------------------------- + // Methods + // --------------------------------------------------------------------------------------------- + + /// Gets a method declared on the type, optionally matching its parameter types. + public static MethodDeclarationSyntax GetMethod( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + params TypeReference[]? parameters + ) => + type.TryGetMethod(query, name, out var method, parameters) + ? method! + : throw new SyntaxNotFoundException( + $"No method named '{name}' was found on '{type.Identifier.ValueText}'." + ); + + /// Determines whether the type declares a method with the given name, optionally matching its parameter types. + public static bool HasMethod( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + params TypeReference[]? parameters + ) => type.TryGetMethod(query, name, out _, parameters); + + /// Attempts to get a method declared on the type, optionally matching its parameter types. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetMethod( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + out MethodDeclarationSyntax? method, + params TypeReference[]? parameters + ) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The method name cannot be null or whitespace.", nameof(name)); + + var expected = parameters ?? []; + foreach (var candidate in type.Members.OfType()) + { + if (candidate.Identifier.ValueText != name) + continue; + + if (expected.Length > 0 && !query.HasParameters(candidate, expected)) + continue; + + method = candidate; + return true; + } + + method = null; + return false; + } + + /// + /// Determines whether the type declares a method with the given name and return type. + /// + public static bool HasMethodReturnType( + this TypeDeclarationSyntax type, + CodeQuery query, + string name, + TypeReference returnType + ) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("The method name cannot be null or whitespace.", nameof(name)); + if (returnType is null) + throw new ArgumentNullException(nameof(returnType)); + + foreach (var candidate in type.Members.OfType()) + { + if (candidate.Identifier.ValueText != name) + continue; + + if (candidate.ReturnType is { } returnTypeSyntax && query.Matches(returnTypeSyntax, returnType)) + return true; + } + + return false; + } + + // --------------------------------------------------------------------------------------------- + // Constructors + // --------------------------------------------------------------------------------------------- + + /// Gets a constructor declared on the type, optionally matching its parameter types. + public static ConstructorDeclarationSyntax GetConstructor( + this TypeDeclarationSyntax type, + CodeQuery query, + params TypeReference[]? parameters + ) => + type.TryGetConstructor(query, out var constructor, parameters) + ? constructor! + : throw new SyntaxNotFoundException($"No constructor was found on '{type.Identifier.ValueText}'."); + + /// Determines whether the type declares a constructor, optionally matching its parameter types. + public static bool HasConstructor( + this TypeDeclarationSyntax type, + CodeQuery query, + params TypeReference[]? parameters + ) => type.TryGetConstructor(query, out _, parameters); + + /// Attempts to get a constructor declared on the type, optionally matching its parameter types. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1021:Avoid out parameters")] + public static bool TryGetConstructor( + this TypeDeclarationSyntax type, + CodeQuery query, + out ConstructorDeclarationSyntax? constructor, + params TypeReference[]? parameters + ) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + if (query is null) + throw new ArgumentNullException(nameof(query)); + + var expected = parameters ?? []; + foreach (var candidate in type.Members.OfType()) + { + if (expected.Length > 0 && !query.HasParameters(candidate, expected)) + continue; + + constructor = candidate; + return true; + } + + constructor = null; + return false; + } + + static bool IndexerParametersMatch(CodeQuery query, IndexerDeclarationSyntax indexer, TypeReference[] expected) + { + var parameters = indexer.ParameterList.Parameters; + if (parameters.Count != expected.Length) + return false; + + for (var index = 0; index < parameters.Count; index++) + { + var typeSyntax = parameters[index].Type; + if (typeSyntax is null || !query.Matches(typeSyntax, expected[index])) + return false; + } + + return true; + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs new file mode 100644 index 0000000..c89ebd9 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/RefactorTestOptions.cs @@ -0,0 +1,26 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Options that configure a refactoring test run. +public record RefactorTestOptions : SourceGeneratorTestOptions +{ + /// Gets the index of the registered code action to apply. + public int CodeActionIndex { get; init; } + + /// Gets the equivalence key used to select a registered code action. + /// When specified, this takes precedence over . + public string? EquivalenceKey { get; init; } + + /// Gets the span the refactoring is triggered on. + /// Either or must be provided. + public TextSpan? Span { get; init; } + + /// + /// Gets a selector that locates the node the refactoring is triggered on, using the input compilation's + /// . For example, query => query.GetMethod("M"). + /// + /// Either or must be provided. + public Func? NodeSelector { get; init; } +} diff --git a/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs b/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs new file mode 100644 index 0000000..c0b7255 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/RefactorTestResult.cs @@ -0,0 +1,17 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// The result of a refactoring test run. +/// The code actions registered by the refactoring provider. +/// The refactored source of each document, keyed by document name. +/// The solution after applying the selected refactoring. +/// The input compilation the refactoring was applied to. +public sealed record RefactorTestResult( + ImmutableArray CodeActions, + ImmutableDictionary FixedSources, + Solution ChangedSolution, + Compilation Compilation +); diff --git a/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs new file mode 100644 index 0000000..485cb4f --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/RefactoringTestRunner.cs @@ -0,0 +1,84 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.Text; + +namespace Purview.SourceGeneratorFramework.Testing; + +/// Executes a code refactoring against a test document and returns the refactored source. +public sealed class RefactoringTestRunner : RoslynTestRunner + where TRefactoring : CodeRefactoringProvider, new() +{ + /// Runs the refactoring against one source file. + public Task RunAsync( + string source, + RefactorTestOptions? options = null, + CancellationToken cancellationToken = default + ) => RunAsync([source], options, cancellationToken); + + /// Runs the refactoring against the supplied source files. + public async Task RunAsync( + IEnumerable sources, + RefactorTestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + options ??= new(); + using var testProject = CreateProject(sources, options, typeof(TRefactoring).Assembly); + var compilation = + await testProject.Project.GetCompilationAsync(cancellationToken) + ?? throw new InvalidOperationException("Unable to create the test compilation."); + + var document = + testProject.Project.Solution.GetDocument(testProject.DocumentIds[0]) + ?? throw new InvalidOperationException("Unable to locate the test document."); + + var span = ResolveSpan(options, compilation); + + List actions = []; + var context = new CodeRefactoringContext(document, span, actions.Add, cancellationToken); + await new TRefactoring().ComputeRefactoringsAsync(context); + + var action = + ( + options.EquivalenceKey is null + ? actions.ElementAtOrDefault(options.CodeActionIndex) + : actions.FirstOrDefault(candidate => candidate.EquivalenceKey == options.EquivalenceKey) + ) ?? throw new InvalidOperationException("The requested refactoring was not registered."); + + var operations = await action.GetOperationsAsync(cancellationToken); + var changedSolution = + operations.OfType().SingleOrDefault()?.ChangedSolution + ?? throw new InvalidOperationException("The refactoring did not produce an ApplyChangesOperation."); + + var fixedSources = ImmutableDictionary.CreateBuilder(); + foreach (var documentId in testProject.DocumentIds) + { + var changedDocument = changedSolution.GetDocument(documentId); + if (changedDocument is null) + continue; + + fixedSources[changedDocument.Name] = (await changedDocument.GetTextAsync(cancellationToken)).ToString(); + } + + return new([.. actions], fixedSources.ToImmutable(), changedSolution, compilation); + } + + static TextSpan ResolveSpan(RefactorTestOptions options, Compilation compilation) + { + if (options.Span is { } explicitSpan) + return explicitSpan; + + if (options.NodeSelector is not null) + { + var query = new CodeQuery([.. compilation.SyntaxTrees], compilation); + + return options.NodeSelector(query).Span; + } + + throw new InvalidOperationException( + "RefactorTestOptions requires a Span or NodeSelector to determine the refactoring trigger." + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs index 2082d5e..59d890c 100644 --- a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs @@ -68,7 +68,12 @@ Assembly componentAssembly LanguageNames.CSharp ) .WithProjectParseOptions(projectId, new CSharpParseOptions(options.LanguageVersion)) - .WithProjectCompilationOptions(projectId, new CSharpCompilationOptions(options.OutputKind)) + .WithProjectCompilationOptions( + projectId, + new CSharpCompilationOptions(options.OutputKind).WithNullableContextOptions( + options.NullableContextOptions + ) + ) .AddMetadataReferences(projectId, SourceGeneratorHelpers.ResolveReferences(options, componentAssembly)); var documentIds = ImmutableArray.CreateBuilder(); diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/.agents/skills/source-generator-testing/SKILL.md b/src/src/SourceGeneratorFramework.Testing/Sdk/.agents/skills/source-generator-testing/SKILL.md new file mode 100644 index 0000000..9d8d12b --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/.agents/skills/source-generator-testing/SKILL.md @@ -0,0 +1,330 @@ +--- +name: source-generator-testing +description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching." +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`sdk` package's `tunit-test-authoring` skill. + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `tunit-test-authoring`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(query, TypeReference.Create(), objectReference); +``` + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty(query, "Count", intType); +service.HasIndexer(query, stringType, intType); +service.HasMethod(query, "Add", intType, complexType); +service.HasMethodReturnType(query, "Add", stringType); +service.HasConstructor(query, stringType); +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the oldest Roslyn the test matrix needs (Roslyn 4.13 for + .NET 8–10); keep `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md index f12c0dc..04dd745 100644 --- a/src/src/SourceGeneratorFramework.Testing/Sdk/README.md +++ b/src/src/SourceGeneratorFramework.Testing/Sdk/README.md @@ -132,6 +132,71 @@ Analyzer options are preserved under their supplied keys. Keys without the Rosly See [`SourceGeneratorFramework.Testing.TUnit`](../SourceGeneratorFramework.Testing.TUnit) for a ready-made TUnit integration. +## Querying produced code with `CodeQuery` + +Every result type exposes a `CodeQuery` so tests can locate syntax nodes in the produced code: + +```csharp +result.Generated() // DriverRunResult: generated trees (generated-first default) +result.Output() // DriverRunResult: whole output compilation +analyzerResult.Code() // AnalyzerTestResult / CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: fixed source +fixAllResult.FixedCode() // CodeFixFixAllResult / RefactorTestResult: changed documents +``` + +`CodeQuery` provides a `Get`/`Has`/`TryGet` family for declarations and members, generic `Get`/`Has`, +syntax-tree lookup, and type-aware matching against `TypeReference`: + +```csharp +var query = result.Generated(); +query.GetClass("ServiceCollectionExtensions").HasMethod(query, "Add", TypeReference.Create()); +query.HasProperty("Count", TypeReference.Create()); +query.GetMethod("DoWork").HasParameters(query, intType, nullableInt, complexType); +query.GetClass("Widget", "Example.Models"); // namespace-scoped lookup +``` + +`Get` throws `SyntaxNotFoundException` when nothing matches; `Has` returns `bool`. See the +`source-generator-testing` agent skill for the full reference. + +## Refactoring tests + +`RefactoringTestRunner` runs a `CodeRefactoringProvider` against a test document: + +```csharp +var runner = new RefactoringTestRunner(); +var result = await runner.RunAsync( + source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("M"), + EquivalenceKey = MyRefactoringProvider.EquivalenceKey, + }); + +result.FixedCode().HasMethod("M"); // query the refactored output +``` + +The trigger is a `Span` or a `NodeSelector` (which runs against a `CodeQuery` of the input compilation). + +## Incremental cache testing + +`SourceGeneratorTestRunner.RunIncrementalAsync` runs the generator over a sequence of source sets using a +single shared driver and captures each run's tracked incremental steps, so tests can prove each pipeline +stage caches correctly: + +```csharp +var result = await runner.RunIncrementalAsync([firstSources, secondSources], options); + +var reasons = result.Runs[1].Steps["ForAttribute_MyAttribute"] + .SelectMany(step => step.Outputs.Select(output => output.Reason)); +``` + +`RunIncrementalAsync(sources, options, ct)` runs the same source set twice (the common "unchanged rerun is +cached" case). Per-run MSBuild-property changes use `new IncrementalRunInput(sources, [...])`. Reference +cache tests live in the `Purview.SourceGeneratorFramework` source repository — +`SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests` (framework stages) and +`SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationCacheTests` (an end-to-end +generator) — and should be replicated into your own test project rather than copied from the package. + ## License This project is licensed under the MIT license. diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj index 1519603..e99f426 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorFramework.Testing.csproj @@ -1,6 +1,8 @@  $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared + + netstandard2.0;$(TestingTargetFrameworks) true $(RootNamespace) diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs index 01d924b..9355b67 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorHelpers.cs @@ -8,9 +8,7 @@ namespace Purview.SourceGeneratorFramework.Testing; static class SourceGeneratorHelpers { - public static readonly string[] TrustedAssemblies = ( - (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? "" - ).Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + public static readonly string[] TrustedAssemblies = ResolveTrustedAssemblies(); public static ImmutableArray ResolveTrustedReferences { get; } = CreateMetadataReferences(TrustedAssemblies); @@ -31,7 +29,7 @@ SourceGeneratorTestOptions options options.CompilationAssemblyName, syntaxTrees, references, - new CSharpCompilationOptions(options.OutputKind) + new CSharpCompilationOptions(options.OutputKind).WithNullableContextOptions(options.NullableContextOptions) ); } @@ -83,6 +81,24 @@ public static string PrepareSource(string source, SourceGeneratorTestOptions opt return builder.Append(source).ToString(); } + static string[] ResolveTrustedAssemblies() + { + var trusted = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? string.Empty; + if (!string.IsNullOrWhiteSpace(trusted)) + return trusted.Split([Path.PathSeparator], StringSplitOptions.RemoveEmptyEntries); + + // .NET Framework does not populate TRUSTED_PLATFORM_ASSEMBLIES; fall back to the loaded + // framework assemblies so test compilations still have their core references. + return + [ + .. AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) + .Select(static assembly => assembly.Location) + .Distinct(StringComparer.OrdinalIgnoreCase), + ]; + } + static ImmutableArray CreateMetadataReferences(string[] paths) { if (paths.Length == 0) diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs index d5b6857..decb6a6 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestBase.cs @@ -96,6 +96,50 @@ protected async Task GenerateAsync( return result; } + /// + /// Runs the generator incrementally over the supplied source sets using a single shared driver, so tests can + /// inspect each pipeline stage's cache reason per run. + /// + /// + /// Runs the generator incrementally over the supplied source sets using a single shared driver, so tests can + /// inspect each pipeline stage's cache reason per run. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1849:Call async methods when in an async method" + )] + protected Task GenerateIncrementalAsync( + IEnumerable inputs, + TOptions? options = null, + CancellationToken cancellationToken = default + ) + { + if (inputs is null) + throw new ArgumentNullException(nameof(inputs)); + + options ??= new(); + var allSources = inputs.SelectMany(input => input.Sources).ToList(); + options = OnBeforeRun(allSources, options, cancellationToken); + options = OnBeforeRunAsync(allSources, options, cancellationToken).GetAwaiter().GetResult(); + + return _runner.RunIncrementalAsync(inputs, options, cancellationToken); + } + + /// + /// Runs the generator against the same source set twice using a single shared driver, proving that an + /// unchanged input is fully cached on the second run. + /// + protected Task GenerateIncrementalAsync( + IEnumerable sources, + TOptions? options = null, + CancellationToken cancellationToken = default + ) => + GenerateIncrementalAsync( + [new IncrementalRunInput(sources), new IncrementalRunInput(sources)], + options, + cancellationToken + ); + /// /// Called after the generator is run, allowing for inspection of the results. /// diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs index a3a72dc..9802398 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestOptions.cs @@ -183,6 +183,13 @@ public SourceGeneratorTestOptions() /// public OutputKind OutputKind { get; init; } = OutputKind.DynamicallyLinkedLibrary; + /// + /// Gets the nullable context of the test compilation. The default is + /// , mirroring the framework's auto-detection of the + /// #nullable enable directive in generated headers. + /// + public NullableContextOptions NullableContextOptions { get; init; } = NullableContextOptions.Disable; + /// /// Gets the language version of the test compilation. /// diff --git a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs index 6c579ca..fe3eca9 100644 --- a/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/SourceGeneratorTestRunner.cs @@ -77,9 +77,7 @@ public async Task RunAsync( Assembly? assembly = null; ImmutableArray compilationDiagnostics = []; if (options.CompileToAssembly) - { - (assembly, compilationDiagnostics) = await CompileToAssemblyAsync(outputCompilation, cancellationToken); - } + (assembly, compilationDiagnostics) = CompileToAssembly(outputCompilation, cancellationToken); var excludedGeneratedSource = ExcludeGeneratedSources(result, options.ExcludeGeneratedSourceHintNames); @@ -93,6 +91,123 @@ [.. logEntries] ); } + /// + /// Runs the generator against a sequence of source sets using a single shared driver, capturing each run's + /// tracked incremental steps so tests can prove which pipeline stages were recomputed (Modified) and + /// which were reused (Cached/Unchanged) between runs. + /// + public Task RunIncrementalAsync( + IEnumerable inputs, + SourceGeneratorTestOptions? options = null, + CancellationToken cancellationToken = default + ) => Task.FromResult(RunIncremental(inputs, options, cancellationToken)); + + /// + /// Runs the generator against the same source set twice using a single shared driver, the common case for + /// proving that an unchanged input is fully cached on the second run. + /// + public Task RunIncrementalAsync( + IEnumerable sources, + SourceGeneratorTestOptions? options = null, + CancellationToken cancellationToken = default + ) => + RunIncrementalAsync( + [new IncrementalRunInput(sources), new IncrementalRunInput(sources)], + options, + cancellationToken + ); + + /// + /// Runs the generator incrementally over the supplied source sets and returns each run's tracked steps. + /// + public IncrementalCacheResult RunIncremental( + IEnumerable inputs, + SourceGeneratorTestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var materializedInputs = inputs?.ToList() ?? throw new ArgumentNullException(nameof(inputs)); + if (materializedInputs.Count == 0) + throw new ArgumentException("At least one run is required.", nameof(inputs)); + + options ??= new(); + if (options.AnalyzerOptions is not null && options.CompilationWithAnalyzersOptions is not null) + { + throw new ArgumentException( + $"{nameof(options.AnalyzerOptions)} and {nameof(options.CompilationWithAnalyzersOptions)} cannot be provided at the same time.", + nameof(options) + ); + } + + TGenerator generator = new(); + var loggingSessionId = options.EnableLogging ? Guid.NewGuid().ToString("N") : null; + var driver = CreateDriver(generator, options, loggingSessionId); + var references = SourceGeneratorHelpers.ResolveReferences(options, typeof(TGenerator).Assembly); + var runs = ImmutableArray.CreateBuilder(materializedInputs.Count); + var compilationCache = new Dictionary(StringComparer.Ordinal); + + foreach (var input in materializedInputs) + { + // Reuse the compilation for identical source sets so Roslyn can report the unchanged pipeline + // stages as cached rather than modified on the second run. + var key = string.Join("\u0001", input.Sources.Select(source => PrepareSource(source, options))); + if (!compilationCache.TryGetValue(key, out var compilation)) + { + compilation = BuildCompilation(input.Sources, options, references, cancellationToken); + compilationCache[key] = compilation; + } + + if (input.AnalyzerConfig is not null) + { + var analyzerOptions = BuildAnalyzerConfig(options, loggingSessionId, input.AnalyzerConfig); + driver = driver.WithUpdatedAnalyzerConfigOptions( + new TestAnalyzerConfigOptionsProvider(analyzerOptions) + ); + } + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _, cancellationToken); + runs.Add(CaptureRun(driver)); + } + + return new(runs.ToImmutable()); + } + + static CSharpCompilation BuildCompilation( + IEnumerable sources, + SourceGeneratorTestOptions options, + ImmutableArray references, + CancellationToken cancellationToken + ) + { + if (!options.AdditionalSources.IsDefaultOrEmpty) + sources = sources.Concat(options.AdditionalSources); + + var syntaxTrees = sources + .Select(source => + CSharpSyntaxTree.ParseText( + PrepareSource(source, options), + encoding: System.Text.Encoding.UTF8, + options: new CSharpParseOptions(options.LanguageVersion), + cancellationToken: cancellationToken + ) + ) + .ToImmutableArray(); + + return SourceGeneratorHelpers.CreateCompilation(syntaxTrees, references, options); + } + + static IncrementalCacheRun CaptureRun(GeneratorDriver driver) + { + var runResult = driver.GetRunResult().Results.FirstOrDefault(run => run.Generator is not null); + var steps = runResult.Generator is null +#pragma warning disable IDE0301 + ? ImmutableDictionary>.Empty +#pragma warning restore IDE0301 + : runResult.TrackedSteps; + + return new(runResult, steps); + } + static async Task GetAnalyzerResultsAsync( SourceGeneratorTestOptions options, Compilation outputCompilation, @@ -147,19 +262,36 @@ static GeneratorDriver CreateDriver( GeneratorDriver driver = CSharpGeneratorDriver.Create( [generator.AsSourceGenerator()], additionalTexts: options.AdditionalText, - parseOptions: new(options.LanguageVersion) + parseOptions: new(options.LanguageVersion), + driverOptions: new GeneratorDriverOptions( + IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true + ) ); + var analyzerOptions = BuildAnalyzerConfig(options, loggingSessionId); + if (analyzerOptions.Count > 0) + driver = driver.WithUpdatedAnalyzerConfigOptions(new TestAnalyzerConfigOptionsProvider(analyzerOptions)); + + return driver; + } + + static Dictionary BuildAnalyzerConfig( + SourceGeneratorTestOptions options, + string? loggingSessionId, + IEnumerable<(string Key, string Value)>? overrides = null + ) + { Dictionary analyzerOptions = new(options.AnalyzerConfigOptions) { [SourceGeneratorBuildProperties.ValidateCodeWriterScopes] = options.ValidateCodeWriterScopes.ToString(), [SourceGeneratorBuildProperties.EnableLogging] = options.EnableLogging.ToString(), }; - foreach (var (key, value) in options.AnalyzerConfigOptions) + foreach (var pair in options.AnalyzerConfigOptions) { - if (!key.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) - analyzerOptions.TryAdd(SourceGeneratorBuildProperties.BuildProperty + key, value); + if (!pair.Key.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + analyzerOptions[SourceGeneratorBuildProperties.BuildProperty + pair.Key] = pair.Value; } if (loggingSessionId is not null) @@ -176,10 +308,13 @@ static GeneratorDriver CreateDriver( analyzerOptions[disablePropertyName] = options.DisableSourceGeneratorValue.Value.ToString(); } - if (analyzerOptions.Count > 0) - driver = driver.WithUpdatedAnalyzerConfigOptions(new TestAnalyzerConfigOptionsProvider(analyzerOptions)); + if (overrides is not null) + { + foreach (var (key, value) in overrides) + analyzerOptions[key] = value; + } - return driver; + return analyzerOptions; } static LoggingRegistrations? ConfigureLogging( @@ -256,12 +391,12 @@ public void Dispose() } } - static async Task<(Assembly?, ImmutableArray)> CompileToAssemblyAsync( + static (Assembly?, ImmutableArray) CompileToAssembly( Compilation compilation, CancellationToken cancellationToken ) { - await using var assemblyStream = new MemoryStream(); + MemoryStream assemblyStream = new(); var emitResult = compilation.Emit(assemblyStream, cancellationToken: cancellationToken); if (!emitResult.Success) diff --git a/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs b/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs new file mode 100644 index 0000000..2b494b9 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Testing/SyntaxNotFoundException.cs @@ -0,0 +1,16 @@ +namespace Purview.SourceGeneratorFramework.Testing; + +/// +/// Raised by CodeQuery when a requested syntax node or tree cannot be located. +/// +[System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1032:Implement standard exception constructors")] +public sealed class SyntaxNotFoundException : InvalidOperationException +{ + /// Initializes a new instance of the class. + public SyntaxNotFoundException(string message) + : base(message) { } + + /// Initializes a new instance of the class. + public SyntaxNotFoundException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index 6e97960..6d5461f 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -162,6 +162,63 @@ See [`SourceGeneratorFramework.ExampleGenerator`](../SourceGeneratorFramework.Ex `CodeWriter` automatically stamps generated declarations with `[GeneratedCode]`, `[CompilerGenerated]`, and `[ExcludeFromCodeCoverage]` (where applicable) using the generator identity supplied to its constructor. The header written by `WriteAutoGeneratedHeader()` is deterministic and does not include a timestamp, so the same inputs always produce the same source. +### The `#nullable enable` directive + +`WriteAutoGeneratedHeader()` emits `#nullable enable` according to a `NullableDirectiveMode`: + +- `Auto` (default) — the framework reads the target compilation's nullable context when the pipeline creates the generation context and emits the directive only when nullable annotations are enabled. When the state is unknown (for example in post-initialization outputs or tests), the directive is still emitted. +- `Always` — always emit `#nullable enable`. +- `Disable` — never emit the directive. + +Override it per call, or set a generator-wide default on `GenerationSettings`: + +```csharp +writer.WriteAutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable); + +var settings = GenerationSettings.Create() with +{ + NullableDirectiveMode = NullableDirectiveMode.Always, +}; +``` + +### Nullable reference annotations in generated types + +`TypeReference.Nullable()` and `TypeIdentity.MakeNullable()` produce nullable annotations. When the target compilation does not support nullable, a nullable *reference* annotation (`string?`) is invalid outside a nullable context and is elided, while a nullable *value* type (`int?`) is always emitted. + +Two mechanisms cooperate: + +- **Context-aware composition** — pass the available `GenerationSettings` or `CodeWriter` to only append the annotation when nullable is enabled or unknown: + +```csharp +writer.WriteType(PurviewTypeLibrary.System.String.MakeNullable(writer)); // elides "?" when nullable is off +``` + +- **Context-aware rendering** — the writer elides reference annotations when it renders with nullable disabled. `WriteType(TypeReference)` renders a bare reference using the writer's nullable context, and `RenderFullNameForNullable(bool)` exposes the same behavior for direct string building: + +```csharp +writer.WriteType(TypeIdentity.Create().MakeNullable()); // "string" when nullable is off, "string?" when on +``` + +The analyzer `PSGFR16` (suggestion) flags bare `Nullable()`/`MakeNullable()` calls and its code fix passes the first in-scope `CodeWriter` or `GenerationSettings`, including project-wide "Fix all" support. + +### Comparing references with or without annotations + +`Equals`/`==` on `TypeReference`/`TypeIdentity` is structural, so `IEnumerable>` does not equal `IEnumerable>`. Mixed comparisons (`TypeIdentity == TypeReference`) are also structural — a reference equals an identity only when it is an unmodified reference to that identity. When a nullable *reference* annotation should be treated as metadata, use `Similar`, which ignores provable reference annotations (but keeps nullable *value* types significant, so `int?` is never similar to `int`): + +```csharp +reference1.Similar(reference2); // reference to reference +reference.Similar(symbol); // reference to ISymbol/ITypeSymbol (uses the same matching as Matches) +``` + +### XML documentation `cref` names + +XML documentation references generic types with `{}` instead of `<>`. Use `XmlCommentWriter.ToXmlCref` or the `XmlCref`/`XmlException`/`XmlSee`/`XmlSeeAlso` overloads that accept a `TypeIdentity`/`TypeReference`: + +```csharp +writer.XmlCref(new TypeIdentity(typeof(List<>)).MakeGeneric(TypeIdentity.Create()), "content"); +// /// content +``` + ## Thin source-output registration For per-target pipelines that return `GeneratorResult`, use `IncrementalPipeline.RegisterSourceOutput` to combine targets with the generation context, report diagnostics, and run the generator callback only for successful results: @@ -683,6 +740,90 @@ session ID is missing, or no matching sink is registered, the provider supplies calls are discarded without storing entries. Test sinks own any entries they choose to capture and are removed when the test run completes. +## Querying generated and fixed code + +Every test result exposes a `CodeQuery` so tests can locate syntax nodes in the produced code. Queries +default to generated code first for source-generator runs, with a `Get`/`Has`/`TryGet` pairing (`Get` +throws `SyntaxNotFoundException` when nothing matches; `Has` returns `bool`). + +```csharp +// Source generators — generated trees first, or the whole output compilation. +var method = result.Generated.GetMethod("DoWork"); // MethodDeclarationSyntax (throws if absent) +bool hasMethod = result.Generated.HasMethod("DoWork"); // true/false +result.Generated.TryGetMethod("DoWork", out var maybe); +var inOutput = result.Output.GetClass("Generated_Service"); // full output compilation + +// Match parameter types using TypeReference, resolving through the compilation's semantic model. +result.Generated.HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated.HasReturnType("Compute", TypeReference.Create()); +result.Generated.GetMethod("Format").HasParameters(query, TypeReference.Create(), objectReference); + +// Other declaration kinds. +result.Generated.GetClass("X"); result.Generated.HasClass("X"); +result.Generated.GetProperty("P"); result.Generated.HasField("_f"); +result.Generated.GetInterface("I"); result.Generated.GetEnum("E"); +result.Generated.GetTypeDeclaration("Record"); +result.Generated.GetNamespace("Example.Nested"); +result.Generated.GetSyntaxTree("Service.g.cs"); result.Generated.Has(predicate); + +// Types can be located in any namespace or a specific one. +result.Generated.GetClass("Widget"); // anywhere +result.Generated.GetClass("Widget", "Example.Models"); // within a namespace + +// Chain from a type declaration to inspect its members, matching return/property/parameter types. +var service = result.Generated.GetClass("ServiceCollectionExtensions"); // or any namespace +service.HasProperty(query, "Count", intType); // property + type +service.HasIndexer(query, stringType, intType); // indexer + return + index param +service.HasMethod(query, "Add", intType, complexType); // method + parameter types +service.HasMethodReturnType(query, "Add", stringType); // method + return type +service.HasConstructor(query, stringType); // ctor + parameter types +service.GetMethod(query, "Add").HasReturnType(query, stringType); +service.GetProperty(query, "Name").HasType(query, stringType); +``` + +Analyzer and code-fix results expose the same API: + +```csharp +analyzerResult.Code.HasMethod("M"); // input compilation +codeFixResult.FixedCode.HasMethod("M"); // parsed fixed source +fixAllResult.FixedCode.HasClass("X"); // post-fix solution documents +refactorResult.FixedCode.HasMethod("M"); // post-refactor documents +``` + +Refactoring tests run a `CodeRefactoringProvider` through `TUnitRefactoringTestBase`, selecting the +trigger node with a `NodeSelector` (or an explicit `Span`): + +```csharp +public class MyRefactoringTests : TUnitRefactoringTestBase +{ + [Test] + public Task AddsAttribute(CancellationToken ct) => RefactorAsync( + source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("DoWork"), + EquivalenceKey = MyRefactoringProvider.EquivalenceKey, + }, + ct); +} +``` + +TUnit assertion extensions return the requested syntax node when awaited: + +```csharp +MethodDeclarationSyntax method = await Assert.That(result).HasGeneratedMethod("DoWork"); +await Assert.That(method.Identifier.ValueText).IsEqualTo("DoWork"); + +var method2 = await Assert.That(result).HasGeneratedMethod("DoWork", [intType, nullableInt, complexType]); +ClassDeclarationSyntax cls = await Assert.That(result).HasGeneratedClass("Service"); +FieldDeclarationSyntax field = await Assert.That(result).HasGeneratedField("Name"); +SyntaxTree tree = await Assert.That(result).HasGeneratedSyntaxTree("Service.g.cs"); + +// Code fix / refactoring results: +var fixedMethod = await Assert.That(codeFixResult).HasFixedMethod("DoWork"); +var refactoredMethod = await Assert.That(refactorResult).HasFixedMethod("DoWork"); +``` + ## Analyzers The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGeneratorFramework.Analyzers` assembly as an analyzer asset. The diagnostics are enabled automatically when you reference `Purview.SourceGeneratorFramework` from a source generator project. diff --git a/src/src/SourceGeneratorShared/CodeWriter.cs b/src/src/SourceGeneratorShared/CodeWriter.cs index 59dbeaa..495a0fc 100644 --- a/src/src/SourceGeneratorShared/CodeWriter.cs +++ b/src/src/SourceGeneratorShared/CodeWriter.cs @@ -65,6 +65,8 @@ public CodeWriter( GeneratorName = settings.GeneratorName; GeneratorVersion = settings.GeneratorVersion; + NullableDirectiveMode = settings.NullableDirectiveMode; + IsNullableContextEnabled = settings.IsNullableContextEnabled; ThrowOnUnclosedScopes = throwOnUnclosedScopes; if (throwOnUnclosedScopes) @@ -99,6 +101,20 @@ public CodeWriter( /// Gets the source generator version used by generated headers and attributes. public string GeneratorVersion { get; } + /// + /// Gets or sets how the #nullable enable directive is emitted by + /// . The value is seeded from + /// at construction. + /// + public NullableDirectiveMode NullableDirectiveMode { get; set; } + + /// + /// Gets or sets whether the target compilation has nullable annotations enabled. The value is + /// seeded from at construction and is + /// when the state is unknown. + /// + public bool? IsNullableContextEnabled { get; set; } + /// /// Gets or sets whether generated attributes are emitted for declarations that do not /// explicitly override , @@ -1320,17 +1336,29 @@ public CodeWriter WriteConstructor(ConstructorDeclarationOptions declaration, Ac /// /// The generator name; defaults to . /// The generator version; defaults to . + /// + /// Controls whether the #nullable enable directive is emitted. When , + /// is used, which defaults to + /// . + /// /// The pragmas to include in the header. /// The current writer. - /// writer.WriteAutoGeneratedHeader(pragmas: ["CS0618"]); + /// + /// writer.WriteAutoGeneratedHeader(pragmas: ["CS0618"]); + /// writer.WriteAutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable); + /// public CodeWriter WriteAutoGeneratedHeader( string? generatorName = null, string? version = null, + NullableDirectiveMode? nullableDirective = null, params string[] pragmas ) { generatorName ??= GeneratorName; version ??= GeneratorVersion; + + var mode = nullableDirective ?? NullableDirectiveMode; + WriteLine("// "); if (!string.IsNullOrEmpty(generatorName)) { @@ -1341,9 +1369,10 @@ params string[] pragmas WriteLine("."); } - WriteLine("// Changes to this file will be lost when the source generator runs again.") - .NewLine() - .WriteLine("#nullable enable"); + WriteLine("// Changes to this file will be lost when the source generator runs again."); + + if (ShouldWriteNullableDirective(mode, IsNullableContextEnabled)) + NewLine().WriteLine("#nullable enable"); if (pragmas is not null && pragmas.Length > 0) { @@ -1357,6 +1386,15 @@ params string[] pragmas return NewLine(); } + static bool ShouldWriteNullableDirective(NullableDirectiveMode mode, bool? isNullableContextEnabled) => + mode switch + { + NullableDirectiveMode.Always => true, + NullableDirectiveMode.Disable => false, + NullableDirectiveMode.Auto => isNullableContextEnabled ?? true, + _ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown nullable directive mode."), + }; + /// /// Writes a declaration. /// @@ -2073,7 +2111,7 @@ CodeWriter WriteParameter(ParameterDeclarationOptions parameter) return this; } - static int GetParameterLength(ParameterDeclarationOptions parameter) + int GetParameterLength(ParameterDeclarationOptions parameter) { var length = GetTypeReferenceLength(GetParameterType(parameter)) + parameter.Name.Length + 1; if (parameter.IsThis) @@ -2769,6 +2807,28 @@ string parameterName } } + /// + /// Writes the given type reference using the writer's nullable context. + /// + /// The type reference to write. + /// The current writer. + /// + /// When the writer's is , nullable reference + /// annotations such as string? are elided because they are invalid outside a nullable context. + /// Nullable value types such as int? are always written. + /// + public CodeWriter WriteType(TypeReference reference) + { + if (reference is null) + throw new ArgumentNullException(nameof(reference)); + if (reference.IsEmpty) + return this; + + ValidateTypeReference(reference, nameof(reference)); + + return Write(reference.RenderFullNameForNullable(IsNullableContextEnabled is not false)); + } + CodeWriter WriteTypeReference(TypeReference reference) { if (reference.IsEmpty) @@ -2776,31 +2836,13 @@ CodeWriter WriteTypeReference(TypeReference reference) ValidateTypeReference(reference, nameof(reference)); - Write(reference.RenderFullName); - - //Write(type.Name); - //if (!type.TypeArguments.IsDefaultOrEmpty) - //{ - // Write('<'); - // for (var index = 0; index < type.TypeArguments.Length; index++) - // { - // if (index != 0) - // Write(", "); - // WriteTypeReference(type.TypeArguments[index]); - // } - // Write('>'); - //} - //else if (type.GenericArity > 0) - // Write('<').Write(new string(',', type.GenericArity - 1)).Write('>'); - - //for (var index = 0; !reference.ArrayRanks.IsDefaultOrEmpty && index < reference.ArrayRanks.Length; index++) - // Write('[').Write(new string(',', reference.ArrayRanks[index] - 1)).Write(']'); - - //WriteIf(reference.IsPointer, "*").WriteIf(reference.IsNullable, "?"); + Write(reference.RenderFullNameForNullable(IsNullableContextEnabled is not false)); + return this; } - static int GetTypeReferenceLength(TypeReference type) => type.IsEmpty ? 0 : type.RenderFullName.Length; + int GetTypeReferenceLength(TypeReference type) => + type.IsEmpty ? 0 : type.RenderFullNameForNullable(IsNullableContextEnabled is not false).Length; static void ValidateTypeReference(TypeReference reference, string parameterName) { diff --git a/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs b/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs deleted file mode 100644 index a1ebdc9..0000000 --- a/src/src/SourceGeneratorShared/Extensions/System/StringExtension.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.ComponentModel; - -namespace System; - -[EditorBrowsable(EditorBrowsableState.Never)] -public static class StringExtension -{ - extension(string? value) - { - /// - /// Surrounds the string with the specified string. Default is double quotes. - /// - /// The string to surround the value with. - /// The surrounded string. - public string Surround(string surroundWith = "\"") => $"{surroundWith}{value}{surroundWith}"; - } -} diff --git a/src/src/SourceGeneratorShared/GenerationSettings.cs b/src/src/SourceGeneratorShared/GenerationSettings.cs index 13287d0..21994a5 100644 --- a/src/src/SourceGeneratorShared/GenerationSettings.cs +++ b/src/src/SourceGeneratorShared/GenerationSettings.cs @@ -30,6 +30,20 @@ public GenerationSettings( /// Gets the optional MSBuild property name that disables the generator when set to true. public string? DisabledSourceGenMSBuildProperty { get; } + /// + /// Gets how the #nullable enable directive is emitted by generated headers written via + /// CodeWriter.WriteAutoGeneratedHeader. The default is , + /// which uses when it is known. + /// + public NullableDirectiveMode NullableDirectiveMode { get; init; } = NullableDirectiveMode.Auto; + + /// + /// Gets whether the target compilation has nullable annotations enabled. The incremental pipeline + /// sets this when the compilation is available; when the value is unknown, + /// such as for post-initialization outputs or tests that construct settings directly. + /// + public bool? IsNullableContextEnabled { get; init; } + /// Gets whether created code writers validate undisposed scopes. public bool ValidateCodeWriterScopes { get; init; } diff --git a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs index 67f1d31..64bb13f 100644 --- a/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs +++ b/src/src/SourceGeneratorShared/Helpers/IncrementalPipeline.cs @@ -1,4 +1,5 @@ using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Purview.SourceGeneratorFramework.Logging; namespace Purview.SourceGeneratorFramework.Helpers; @@ -34,6 +35,15 @@ public static IncrementalValueProvider PropertyValueProvider( if (!propertyName.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) msbuildPropertyValue = SourceGeneratorBuildProperties.BuildProperty + propertyName; + // Use the bare property name for the tracking name so cache tests reference the human-readable + // stage (for example GetMSBuildPropertyValue_EmitServiceInfo) rather than the build_property.* key. + var trackingName = propertyName.StartsWith( + SourceGeneratorBuildProperties.BuildProperty, + StringComparison.Ordinal + ) + ? propertyName.Substring(SourceGeneratorBuildProperties.BuildProperty.Length) + : propertyName; + // All valid... return context .AnalyzerConfigOptionsProvider.Select( @@ -44,7 +54,7 @@ public static IncrementalValueProvider PropertyValueProvider( return converter(value); } ) - .WithTrackingName($"GetMSBuildPropertyValue_{propertyName}"); + .WithTrackingName($"GetMSBuildPropertyValue_{trackingName}"); } /// @@ -93,6 +103,7 @@ public static IncrementalValueProvider< ValidateCodeWriterScopes = configuration.ValidateCodeWriterScopes, IsSourceGeneratorDisabled = configuration.IsSourceGeneratorDisabled, IsLoggingEnabled = logger is not null, + IsNullableContextEnabled = IsNullableContextEnabled(compilation), }; var capabilities = factory(compilation, settings, logger, cancellationToken); @@ -103,6 +114,20 @@ public static IncrementalValueProvider< .WithTrackingName($"GetGenerationContext_{typeof(TCapabilities).Name}"); } + /// + /// Determines whether the compilation has nullable annotations enabled, which is the case when + /// the compilation options allow nullable annotations. Returns when the + /// compilation is not a C# compilation. + /// +#pragma warning disable format + static bool? IsNullableContextEnabled(Compilation compilation) => + compilation + is CSharpCompilation + { + Options.NullableContextOptions: NullableContextOptions.Annotations or NullableContextOptions.Enable + }; +#pragma warning restore format + static IncrementalValueProvider GenerationConfigurationValueProvider( IncrementalGeneratorInitializationContext context, string? disablePropertyName diff --git a/src/src/SourceGeneratorShared/NullableDirectiveMode.cs b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs new file mode 100644 index 0000000..fb33f74 --- /dev/null +++ b/src/src/SourceGeneratorShared/NullableDirectiveMode.cs @@ -0,0 +1,20 @@ +namespace Purview.SourceGeneratorFramework; + +/// +/// Controls whether the #nullable enable directive is emitted by WriteAutoGeneratedHeader. +/// +public enum NullableDirectiveMode +{ + /// + /// Detects whether the target compilation has nullable annotations enabled and emits the + /// #nullable enable directive only when it does. When the compilation state is unknown, + /// the directive is emitted to preserve existing behavior. + /// + Auto = 0, + + /// Always emits the #nullable enable directive. + Always = 1, + + /// Never emits the #nullable enable directive. + Disable = 2, +} diff --git a/src/src/SourceGeneratorShared/TypeIdentity.cs b/src/src/SourceGeneratorShared/TypeIdentity.cs index 5b6fc8f..3f0a28d 100644 --- a/src/src/SourceGeneratorShared/TypeIdentity.cs +++ b/src/src/SourceGeneratorShared/TypeIdentity.cs @@ -320,20 +320,30 @@ public string MetadataFullName /// /// Gets the fully-qualified global type name for use in generated code. /// - public string RenderFullName => RenderFullNameCore(omitAttributeSuffix: false); + public string RenderFullName => RenderFullNameForNullable(nullableSupported: true); + + /// + /// Gets the fully-qualified global type name for use in generated code. + /// + /// + /// When , nullable reference annotations on generic arguments (such as the + /// string? in List<string?>) are elided. + /// + public string RenderFullNameForNullable(bool nullableSupported) => + RenderFullNameCore(omitAttributeSuffix: false, nullableSupported); /// /// Gets the fully-qualified name for use in an attribute application, omitting the optional /// Attribute suffix from the outer type name. /// - public string RenderAttributeName => RenderFullNameCore(omitAttributeSuffix: true); + public string RenderAttributeName => RenderFullNameCore(omitAttributeSuffix: true, nullableSupported: true); - string RenderFullNameCore(bool omitAttributeSuffix) + string RenderFullNameCore(bool omitAttributeSuffix, bool nullableSupported) { if (SpecialType != SpecialType.None) return Keyword!; - var name = RenderTypeNameCore(omitAttributeSuffix); + var name = RenderTypeNameCore(omitAttributeSuffix, nullableSupported); if (IsNested) name = $"{string.Join(".", ContainingTypes.Select(static type => type.RenderTypeName))}.{name}"; @@ -343,15 +353,15 @@ string RenderFullNameCore(bool omitAttributeSuffix) /// /// Gets the type name suitable for use in generated code, without namespace or containing types. /// - public string RenderTypeName => RenderTypeNameCore(omitAttributeSuffix: false); + public string RenderTypeName => RenderTypeNameCore(omitAttributeSuffix: false, nullableSupported: true); /// /// Gets the unqualified type name for use in an attribute application, omitting the optional /// Attribute suffix while retaining generic arguments. /// - public string RenderAttributeTypeName => RenderTypeNameCore(omitAttributeSuffix: true); + public string RenderAttributeTypeName => RenderTypeNameCore(omitAttributeSuffix: true, nullableSupported: true); - string RenderTypeNameCore(bool omitAttributeSuffix) + string RenderTypeNameCore(bool omitAttributeSuffix, bool nullableSupported) { if (SpecialType != SpecialType.None) return Keyword!; @@ -367,7 +377,7 @@ string RenderTypeNameCore(bool omitAttributeSuffix) // Render the open generic definition with commas for each type parameter, or the constructed form with return TypeArguments.IsDefaultOrEmpty ? $"{name}<{new string(',', GenericArity - 1)}>" - : $"{name}<{string.Join(", ", TypeArguments.Select(static argument => argument.RenderFullName))}>"; + : $"{name}<{string.Join(", ", TypeArguments.Select(argument => argument.RenderFullNameForNullable(nullableSupported)))}>"; } /// @@ -469,6 +479,54 @@ public bool Equals(TypeIdentity other) => && ContainingTypesEqual(ContainingTypes, other.ContainingTypes) && TypeArgumentsEqual(TypeArguments, other.TypeArguments); + /// + /// Determines whether the specified value represents the same type, ignoring nullable reference + /// annotations on generic arguments. + /// + /// + /// Nullable reference annotations are metadata rather than identity, so a provable reference annotation + /// is ignored. Nullable value types remain significant, so int? is never similar to + /// int. + /// + public bool Similar(TypeIdentity other) => + string.Equals(Name, other.Name, StringComparison.Ordinal) + && GenericArity == other.GenericArity + && SpecialType == other.SpecialType + && string.Equals(Namespace, other.Namespace, StringComparison.Ordinal) + && string.Equals(Keyword, other.Keyword, StringComparison.Ordinal) + && ContainingTypesEqual(ContainingTypes, other.ContainingTypes) + && TypeArgumentsSimilar(TypeArguments, other.TypeArguments); + + /// + /// Determines whether the type of the specified symbol is similar to this type, ignoring nullable + /// reference annotations. + /// + /// + /// An alias for retained for call-site ergonomics. + /// + public bool Similar(ITypeSymbol? other) => Matches(other); + + /// + /// Determines whether the type of the specified member symbol is similar to this type, ignoring nullable + /// reference annotations. + /// + /// + /// An alias for retained for call-site ergonomics. + /// + public bool Similar(ISymbol? other) => Matches(other); + + /// + /// Compares this identity against a reference. Because both types define implicit conversions to one + /// another, this operator pins the comparison so that identity == reference resolves without the + /// ambiguity that the two record == operators would otherwise introduce. The reference matches only + /// when it is an unmodified reference to this identity. + /// + public static bool operator ==(TypeIdentity left, TypeReference? right) => + right is not null && right.IsPlainNamedType && right.Identity.Equals(left); + + /// Negates . + public static bool operator !=(TypeIdentity left, TypeReference? right) => !(left == right); + /// /// Returns a structural hash code for this type, its containing types and its generic arguments. /// @@ -513,6 +571,10 @@ public override int GetHashCode() /// Creates a nullable structured type reference. public TypeReference MakeNullable() => AsTypeReference().Nullable(); + public TypeReference MakeNullable(GenerationSettings settings) => AsTypeReference().Nullable(settings); + + public TypeReference MakeNullable(CodeWriter writer) => AsTypeReference().Nullable(writer); + /// Creates an array structured type reference with the specified rank. public TypeReference MakeArray(int rank = 1) => AsTypeReference().MakeArray(rank); @@ -637,7 +699,7 @@ public TypeIdentity MakeGeneric(params TypeReference[] typeArguments) /// /// /// Runtime values do not retain nullable-reference annotations. To represent - /// string?, create the string value then call , or use + /// string?, create the string value then call , or use /// when working /// with Roslyn symbols. /// @@ -854,6 +916,23 @@ static bool TypeArgumentsEqual(ImmutableArray left, ImmutableArra return true; } + static bool TypeArgumentsSimilar(ImmutableArray left, ImmutableArray right) + { + var leftCount = left.IsDefaultOrEmpty ? 0 : left.Length; + var rightCount = right.IsDefaultOrEmpty ? 0 : right.Length; + + if (leftCount != rightCount) + return false; + + for (var index = 0; index < leftCount; index++) + { + if (!left[index].Similar(right[index])) + return false; + } + + return true; + } + /// /// Builds the containing-type chain from a symbol: one length pass, one fill pass, one allocation. /// diff --git a/src/src/SourceGeneratorShared/TypeModifier.cs b/src/src/SourceGeneratorShared/TypeModifier.cs index f8a8894..aa15cb0 100644 --- a/src/src/SourceGeneratorShared/TypeModifier.cs +++ b/src/src/SourceGeneratorShared/TypeModifier.cs @@ -18,6 +18,29 @@ public enum TypeModifierKind Array = 2, } +/// +/// Distinguishes how a modifier was formed, which determines +/// whether the ? may be elided when the target compilation does not support nullable annotations. +/// +/// +/// A nullable value type (int?, Nullable<T>) is valid in any nullable context and is +/// never elided. A nullable reference annotation (string?) triggers CS8632 outside a nullable +/// context and is elided when unsupported. is used when the value-versus-reference +/// question cannot be answered without a compilation; such annotations are never elided so a genuine value +/// type cannot be silently changed. +/// +public enum NullableModifierKind +{ + /// The value-versus-reference question is unknown. + Unknown = 0, + + /// The modifier represents a nullable value type, such as int? or Nullable<T>. + ValueType = 1, + + /// The modifier represents a nullable reference type annotation, such as string?. + Reference = 2, +} + /// /// A single composition step applied to a type reference. /// @@ -25,7 +48,7 @@ public enum TypeModifierKind /// Modifiers are stored innermost-first, so int?[] is [Nullable, Array(1)] and int[]? /// is [Array(1), Nullable]. Rendering appends each suffix in order; matching consumes them in reverse. /// -public readonly record struct TypeModifier +public readonly struct TypeModifier : IEquatable { /// Gets the kind of composition step. public TypeModifierKind Kind { get; init; } @@ -33,9 +56,34 @@ public readonly record struct TypeModifier /// Gets the array rank. Only meaningful when is . public int Rank { get; init; } - /// Gets a nullable modifier. + /// + /// Gets how a modifier was formed. This is render-only metadata + /// and is deliberately excluded from equality and hashing, so a symbol-sourced string? still + /// compares equal to a composed MakeNullable() one. + /// + public NullableModifierKind NullableKind { get; init; } + + /// Gets a nullable modifier whose value-versus-reference classification is unknown. public static TypeModifier Nullable => new() { Kind = TypeModifierKind.Nullable, Rank = 0 }; + /// Gets a nullable modifier representing a nullable value type. + public static TypeModifier NullableValueType => + new() + { + Kind = TypeModifierKind.Nullable, + Rank = 0, + NullableKind = NullableModifierKind.ValueType, + }; + + /// Gets a nullable modifier representing a nullable reference type annotation. + public static TypeModifier NullableReference => + new() + { + Kind = TypeModifierKind.Nullable, + Rank = 0, + NullableKind = NullableModifierKind.Reference, + }; + /// Gets a pointer modifier. public static TypeModifier PointerModifier => new() { Kind = TypeModifierKind.PointerModifier, Rank = 0 }; @@ -46,7 +94,7 @@ public static TypeModifier Array(int rank = 1) if (rank < 1) throw new ArgumentOutOfRangeException(nameof(rank), rank, "An array rank must be at least one."); - // The rank is stored in the modifier, but it is not used for equality or hashing. This is because the rank is not part of the C# type system; it is only used for rendering. + // The rank is stored in the Rank property, but the Kind is always Array. return new() { Kind = TypeModifierKind.Array, Rank = rank }; } @@ -64,4 +112,28 @@ public static TypeModifier Array(int rank = 1) /// public override string ToString() => Suffix; + + /// + /// Compares modifiers by their structural shape, ignoring the render-only + /// classification. + /// + public bool Equals(TypeModifier other) => Kind == other.Kind && Rank == other.Rank; + + /// + public override bool Equals(object? obj) => obj is TypeModifier other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + return ((int)Kind * 397) ^ Rank; + } + } + + /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + public static bool operator ==(TypeModifier left, TypeModifier right) => left.Equals(right); + + /// Compares modifiers by their structural shape, ignoring the render-only nullable classification. + public static bool operator !=(TypeModifier left, TypeModifier right) => !left.Equals(right); } diff --git a/src/src/SourceGeneratorShared/TypeReference.cs b/src/src/SourceGeneratorShared/TypeReference.cs index b40c458..31976e5 100644 --- a/src/src/SourceGeneratorShared/TypeReference.cs +++ b/src/src/SourceGeneratorShared/TypeReference.cs @@ -96,48 +96,71 @@ public TypeReference(TypeIdentity typeIdentity) /// /// Gets the fully-qualified reference as it should be rendered in generated code. /// + /// + /// Equivalent to with , so nullable + /// reference annotations are always rendered. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + public string RenderFullName => RenderFullNameForNullable(nullableSupported: true); + + /// + /// Gets the fully-qualified reference as it should be rendered in generated code. + /// + /// + /// When , nullable reference annotations (string?) are elided because + /// they are invalid outside a nullable context. Nullable value types (int?) and unclassified + /// annotations are always rendered. + /// [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] - public string RenderFullName + public string RenderFullNameForNullable(bool nullableSupported) { - get + var core = Kind switch { - var core = Kind switch - { - TypeReferenceKind.Named => Identity.RenderFullName, - TypeReferenceKind.TypeParameter => TypeParameterName ?? string.Empty, - TypeReferenceKind.Dynamic => "dynamic", - _ => string.Empty, - }; - - if (Modifiers.IsDefaultOrEmpty) - return core; - - StringBuilder builder = new(core); - - // `?` and `*` read innermost-first, but a run of array declarators reads outermost-first: - // `int[][,]` is a rank-1 array of rank-2 arrays. Each contiguous array run is therefore emitted - // in reverse. - var index = 0; - while (index < Modifiers.Length) - { - if (Modifiers[index].Kind != TypeModifierKind.Array) - { - builder.Append(Modifiers[index].Suffix); - index++; + TypeReferenceKind.Named => Identity.RenderFullNameForNullable(nullableSupported), + TypeReferenceKind.TypeParameter => TypeParameterName ?? string.Empty, + TypeReferenceKind.Dynamic => "dynamic", + _ => string.Empty, + }; - continue; - } + if (Modifiers.IsDefaultOrEmpty) + return core; - var start = index; - while (index < Modifiers.Length && Modifiers[index].Kind == TypeModifierKind.Array) - index++; + StringBuilder builder = new(core); - for (var reverse = index - 1; reverse >= start; reverse--) - builder.Append(Modifiers[reverse].Suffix); + // `?` and `*` read innermost-first, but a run of array declarators reads outermost-first: + // `int[][,]` is a rank-1 array of rank-2 arrays. Each contiguous array run is therefore emitted + // in reverse. + var index = 0; + while (index < Modifiers.Length) + { + if (Modifiers[index].Kind != TypeModifierKind.Array) + { + var modifier = Modifiers[index]; + if (ShouldRender(modifier, nullableSupported)) + builder.Append(modifier.Suffix); + index++; + + continue; } - return builder.ToString(); + var start = index; + while (index < Modifiers.Length && Modifiers[index].Kind == TypeModifierKind.Array) + index++; + + for (var reverse = index - 1; reverse >= start; reverse--) + builder.Append(Modifiers[reverse].Suffix); } + + return builder.ToString(); + } + + static bool ShouldRender(TypeModifier modifier, bool nullableSupported) + { + if (modifier.Kind != TypeModifierKind.Nullable) + return true; + + // Nullable value types and unclassified annotations are always rendered, because they are part of the type identity. + return nullableSupported || modifier.NullableKind != NullableModifierKind.Reference; } /// @@ -189,7 +212,34 @@ public string RenderAttributeName // --------------------------------------------------------------------------------------------- /// Appends a nullable annotation. - public TypeReference Nullable() => Append(TypeModifier.Nullable); + /// + /// The annotation is classified from the annotated type where possible, so a nullable value type such as + /// int? is never elided when the target compilation does not support nullable annotations, while a + /// nullable reference annotation such as string? is. + /// + public TypeReference Nullable() => AppendNullable(TypeModifier.Nullable); + + /// + /// Appends a nullable annotation if the given settings indicate that nullable context is enabled or unknown. + /// + /// The generation settings to use. + /// The modified type reference. + /// If is . + public TypeReference Nullable(GenerationSettings settings) => + settings == null ? throw new ArgumentNullException(nameof(settings)) + : settings.IsNullableContextEnabled is null or true ? AppendNullable(TypeModifier.Nullable) + : this; + + /// + /// Appends a nullable annotation if the given settings indicate that nullable context is enabled or unknown. + /// + /// The code writer to use. + /// The modified type reference. + /// If is . + public TypeReference Nullable(CodeWriter writer) => + writer == null ? throw new ArgumentNullException(nameof(writer)) + : writer.IsNullableContextEnabled is null or true ? AppendNullable(TypeModifier.Nullable) + : this; /// Appends an array of the given rank. public TypeReference MakeArray(int rank = 1) => Append(TypeModifier.Array(rank)); @@ -197,6 +247,59 @@ public string RenderAttributeName /// Appends a pointer indirection. public TypeReference MakePointer() => Append(TypeModifier.PointerModifier); + TypeReference AppendNullable(TypeModifier nullable) + { + if (nullable.Kind == TypeModifierKind.Nullable && nullable.NullableKind == NullableModifierKind.Unknown) + { + var inferred = InferNullableKind(); + if (inferred != NullableModifierKind.Unknown) + nullable = nullable with { NullableKind = inferred }; + } + + return Append(nullable); + } + + /// + /// Classifies a nullable annotation appended to this reference from the annotated type, when it can be + /// determined without a compilation. Arrays and are reference types; pointers are + /// value types; a keyword answers from its runtime ; anything + /// else is unknown. + /// + NullableModifierKind InferNullableKind() + { + if (!Modifiers.IsDefaultOrEmpty) + { + foreach (var modifier in Modifiers) + { + if (modifier.Kind == TypeModifierKind.Array) + return NullableModifierKind.Reference; + if (modifier.Kind == TypeModifierKind.PointerModifier) + return NullableModifierKind.ValueType; + } + } + +#pragma warning disable IDE0072 // Add missing cases + return Kind switch + { + TypeReferenceKind.Named => InferNamedNullableKind(Identity), + TypeReferenceKind.Dynamic => NullableModifierKind.Reference, + _ => NullableModifierKind.Unknown, + }; +#pragma warning restore IDE0072 // Add missing cases + } + + static NullableModifierKind InferNamedNullableKind(TypeIdentity identity) + { + if (identity.SpecialType == SpecialType.None) + return NullableModifierKind.Unknown; + + var mapping = KnownLangTypes.Get(identity.SpecialType); + + return mapping.IsEmpty ? NullableModifierKind.Unknown + : mapping.Type.IsValueType ? NullableModifierKind.ValueType + : NullableModifierKind.Reference; + } + TypeReference Append(TypeModifier modifier) { if (Kind == TypeReferenceKind.None) @@ -303,6 +406,18 @@ current is INamedTypeSymbol nullable /// public bool Equals(TypeIdentity other) => IsPlainNamedType && Identity.Equals(other); + /// + /// Compares this reference against an identity. Because both types define implicit conversions to one + /// another, this operator pins the comparison so that reference == identity resolves without the + /// ambiguity that the two record == operators would otherwise introduce. The reference matches only + /// when it is an unmodified reference to the identity. + /// + public static bool operator ==(TypeReference? left, TypeIdentity right) => + left is not null && left.IsPlainNamedType && left.Identity.Equals(right); + + /// Negates . + public static bool operator !=(TypeReference? left, TypeIdentity right) => !(left == right); + /// /// Determines whether the specified reference describes the same composed type. /// @@ -310,6 +425,11 @@ current is INamedTypeSymbol nullable /// Declared explicitly because the synthesised record equality would compare /// by its default comparer, which is reference equality on the underlying /// array rather than structural equality of the modifiers. + /// + /// This comparison is structural: nullable reference annotations are significant, so string? is not + /// equal to string. Use when annotations should be treated as + /// metadata. + /// /// public bool Equals(TypeReference? other) { @@ -340,6 +460,90 @@ public bool Equals(TypeReference? other) return true; } + /// + /// Determines whether the specified reference describes the same composed type, ignoring nullable + /// reference annotations. + /// + /// + /// Nullable reference annotations are metadata rather than identity, so a provable reference annotation + /// () is ignored on either side. Nullable value types + /// and unclassified annotations remain significant, so int? is never similar to int while + /// string? is similar to string. + /// + public bool Similar(TypeReference? other) + { + if (ReferenceEquals(this, other)) + return true; + + if (other is null || Kind != other.Kind) + return false; + + if (!string.Equals(TypeParameterName, other.TypeParameterName, StringComparison.Ordinal)) + return false; + + if (Kind == TypeReferenceKind.Named && !Identity.Similar(other.Identity)) + return false; + + var index = 0; + var otherIndex = 0; + var count = Modifiers.IsDefaultOrEmpty ? 0 : Modifiers.Length; + var otherCount = other.Modifiers.IsDefaultOrEmpty ? 0 : other.Modifiers.Length; + + while (index < count || otherIndex < otherCount) + { + var modifier = index < count ? Modifiers[index] : (TypeModifier?)null; + var otherModifier = otherIndex < otherCount ? other.Modifiers[otherIndex] : (TypeModifier?)null; + + if (IsReferenceAnnotation(modifier)) + { + index++; + continue; + } + + if (IsReferenceAnnotation(otherModifier)) + { + otherIndex++; + continue; + } + + if (modifier is null || otherModifier is null) + return false; + + if ( + modifier.Value.Kind != otherModifier.Value.Kind + || modifier.Value.Rank != otherModifier.Value.Rank + || modifier.Value.NullableKind != otherModifier.Value.NullableKind + ) + return false; + + index++; + otherIndex++; + } + + return true; + } + + static bool IsReferenceAnnotation(TypeModifier? modifier) => + modifier is { Kind: TypeModifierKind.Nullable, NullableKind: NullableModifierKind.Reference }; + + /// + /// Determines whether the type of the specified symbol is similar to this reference, ignoring nullable + /// reference annotations. + /// + /// + /// An alias for retained for call-site ergonomics. + /// + public bool Similar(ITypeSymbol? other) => Matches(other); + + /// + /// Determines whether the type of the specified member symbol is similar to this reference, ignoring + /// nullable reference annotations. + /// + /// + /// An alias for retained for call-site ergonomics. + /// + public bool Similar(ISymbol? other) => Matches(other); + /// public override int GetHashCode() { @@ -459,7 +663,7 @@ typeSymbol is INamedTypeSymbol named while (true) { if (current.IsReferenceType && current.NullableAnnotation == NullableAnnotation.Annotated) - modifiers.Add(TypeModifier.Nullable); + modifiers.Add(TypeModifier.NullableReference); switch (current) { @@ -478,7 +682,7 @@ typeSymbol is INamedTypeSymbol named case INamedTypeSymbol nullable when nullable.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && nullable.TypeArguments.Length == 1: - modifiers.Add(TypeModifier.Nullable); + modifiers.Add(TypeModifier.NullableValueType); current = nullable.TypeArguments[0]; continue; @@ -549,7 +753,7 @@ public static bool TryCreate(Type? type, out TypeReference value) var underlying = System.Nullable.GetUnderlyingType(current); if (underlying is not null) { - modifiers.Add(TypeModifier.Nullable); + modifiers.Add(TypeModifier.NullableValueType); current = underlying; continue; diff --git a/src/src/SourceGeneratorShared/XmlCommentWriter.cs b/src/src/SourceGeneratorShared/XmlCommentWriter.cs index 11a3b61..a6c85e9 100644 --- a/src/src/SourceGeneratorShared/XmlCommentWriter.cs +++ b/src/src/SourceGeneratorShared/XmlCommentWriter.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Text; +using Microsoft.CodeAnalysis; namespace Purview.SourceGeneratorFramework; @@ -89,22 +90,46 @@ public CodeWriter XmlException(string exceptionType, params string[] content) => : XmlCore(writer, BuildXmlTag("exception", ("cref", exceptionType)), "exception", content); /// - /// Writes an XML <returns> documentation block with the specified content. + /// Writes an XML <exception> documentation block with the specified exception type and content. /// - /// The type of the exception. + /// The exception type. /// The content lines. /// The current writer. public CodeWriter XmlException(TypeIdentity exceptionType, params string[] content) => - XmlCore(writer, BuildXmlTag("exception", ("cref", exceptionType)), "exception", content); + XmlCore(writer, BuildXmlTag("exception", ("cref", ToXmlCref(exceptionType))), "exception", content); /// - /// Writes an XML cref documentation block with the specified type name and content. + /// Writes an XML <exception> documentation block with the specified exception type and content. /// - /// The name of the type. + /// The exception type reference. + /// The content lines. + /// The current writer. + /// If is . + public CodeWriter XmlException(TypeReference exceptionType, params string[] content) => + exceptionType == null + ? throw new ArgumentNullException(nameof(exceptionType)) + : XmlCore(writer, BuildXmlTag("exception", ("cref", ToXmlCref(exceptionType))), "exception", content); + + /// + /// Writes an XML cref documentation block with the specified type and content. + /// + /// The type. /// The content lines. /// The current writer. public CodeWriter XmlCref(TypeIdentity typeName, params string[] content) => - XmlCore(writer, BuildXmlTag("cref", ("cref", typeName)), "cref", content); + XmlCore(writer, BuildXmlTag("cref", ("cref", ToXmlCref(typeName))), "cref", content); + + /// + /// Writes an XML cref documentation block with the specified type and content. + /// + /// The type reference. + /// The content lines. + /// The current writer. + /// If is . + public CodeWriter XmlCref(TypeReference typeName, params string[] content) => + typeName == null + ? throw new ArgumentNullException(nameof(typeName)) + : XmlCore(writer, BuildXmlTag("cref", ("cref", ToXmlCref(typeName))), "cref", content); /// /// Writes an XML <c> documentation block with the specified content. @@ -143,6 +168,30 @@ public CodeWriter XmlSeeAlso(string cref, params string[] content) : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); } + /// Writes an XML <seealso> documentation element for a type. + public CodeWriter XmlSeeAlso(TypeIdentity type, params string[] content) + { + var cref = ToXmlCref(type); + + return content is null || content.Length == 0 + ? writer.Write("/// ").WriteLine(BuildSelfClosingXmlTag("seealso", ("cref", cref))) + : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); + } + + /// Writes an XML <seealso> documentation element for a type reference. + /// If is . + public CodeWriter XmlSeeAlso(TypeReference type, params string[] content) + { + if (type is null) + throw new ArgumentNullException(nameof(type)); + + var cref = ToXmlCref(type); + + return content is null || content.Length == 0 + ? writer.Write("/// ").WriteLine(BuildSelfClosingXmlTag("seealso", ("cref", cref))) + : XmlCore(writer, BuildXmlTag("seealso", ("cref", cref)), "seealso", content); + } + /// Writes a self-closing XML <include /> documentation element. public CodeWriter XmlInclude(string file, string path) { @@ -390,6 +439,147 @@ public static string XmlSee(string cref, string? description = null) : BuildXmlTag("see", ("cref", cref)) + description + ""; } + /// Returns an inline XML reference to a type. + public static string XmlSee(TypeIdentity type, string? description = null) => + XmlSee(ToXmlCref(type), description); + + /// Returns an inline XML reference to a type reference. + /// If is . + public static string XmlSee(TypeReference type, string? description = null) => + type == null ? throw new ArgumentNullException(nameof(type)) : XmlSee(ToXmlCref(type), description); + + /// + /// Returns the XML-documentation cref name for a type, using the brace form for generics that + /// XML documentation requires (<see cref="global::System.Collections.Generic.List{global::System.String}" />). + /// + /// The type. + /// The cref-safe name. + public static string ToXmlCref(TypeIdentity type) + { + if (type == TypeIdentity.Empty) + return string.Empty; + + if (type.SpecialType != SpecialType.None) + return type.Keyword!; + + var name = ToXmlCrefTypeName(type, omitAttributeSuffix: false); + if (type.IsNested) + name = + $"{string.Join(".", type.ContainingTypes.Select(static containing => ToXmlCrefContainingName(containing)))}.{name}"; + + return type.IsGlobalNamespace ? name : $"global::{type.Namespace}.{name}"; + } + + /// + /// Returns the XML-documentation cref name for a type reference, using the brace form for + /// generics that XML documentation requires. Nullable value types are rendered as + /// global::System.Nullable{...}; nullable reference annotations are not representable in a + /// cref and are rendered as their base type. + /// + /// The type reference. + /// The cref-safe name. + /// If is . + public static string ToXmlCref(TypeReference reference) + { + if (reference is null) + throw new ArgumentNullException(nameof(reference)); + if (reference.IsEmpty) + return string.Empty; + +#pragma warning disable IDE0072 // Add missing cases + var core = reference.Kind switch + { + TypeReferenceKind.Named => ToXmlCref(reference.Identity), + TypeReferenceKind.TypeParameter => "{" + (reference.TypeParameterName ?? string.Empty) + "}", + TypeReferenceKind.Dynamic => "dynamic", + _ => string.Empty, + }; +#pragma warning restore IDE0072 // Add missing cases + + if (reference.Modifiers.IsDefaultOrEmpty) + return core; + + StringBuilder builder = new(core); + var modifiers = reference.Modifiers; + var index = 0; + while (index < modifiers.Length) + { + var modifier = modifiers[index]; + switch (modifier.Kind) + { + case TypeModifierKind.Array: +#pragma warning disable format + { + var start = index; + while (index < modifiers.Length && modifiers[index].Kind == TypeModifierKind.Array) + index++; + + for (var reverse = index - 1; reverse >= start; reverse--) + AppendArraySuffix(builder, modifiers[reverse].Rank); + + continue; + } +#pragma warning restore format + + case TypeModifierKind.Nullable: + if (modifier.NullableKind == NullableModifierKind.ValueType) + WrapInNullable(builder); + + break; + + case TypeModifierKind.PointerModifier: + builder.Append('*'); + break; + + default: + break; + } + + index++; + } + + return builder.ToString(); + } + + static string ToXmlCrefTypeName(TypeIdentity type, bool omitAttributeSuffix) + { + var name = + omitAttributeSuffix && type.IsAttribute + ? type.Name.Substring(0, type.Name.Length - TypeHelpers.AttributeSuffix.Length) + : type.Name; + + if (type.GenericArity == 0) + return name; + + // Constructed generics use their actual arguments; an open definition uses readable placeholders + // because the type parameter names are not retained by the value object. + var arguments = type.TypeArguments.IsDefaultOrEmpty + ? Enumerable.Range(0, type.GenericArity).Select(static index => $"T{index}") + : type.TypeArguments.Select(static argument => ToXmlCref(argument)); + + return $"{name}{{{string.Join(",", arguments)}}}"; + } + + static string ToXmlCrefContainingName(ContainingType containing) => + containing.GenericArity == 0 + ? containing.Name + : $"{containing.Name}{{{string.Join(",", Enumerable.Range(0, containing.GenericArity).Select(static index => $"T{index}"))}}}"; + + static void AppendArraySuffix(StringBuilder builder, int rank) + { + builder.Append('['); + if (rank > 1) + builder.Append(',', rank - 1); + builder.Append(']'); + } + + static void WrapInNullable(StringBuilder builder) + { + var inner = builder.ToString(); + builder.Clear(); + builder.Append("global::System.Nullable{").Append(inner).Append('}'); + } + /// Returns an inline XML <para> element containing the provided content. public static string XmlInlinePara(params string[] content) => XmlCore("para", content, false); diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferNullableContextOverloadAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferNullableContextOverloadAnalyzerTests.cs new file mode 100644 index 0000000..293a238 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferNullableContextOverloadAnalyzerTests.cs @@ -0,0 +1,109 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class PreferNullableContextOverloadAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task MakeNullable_BareCall_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit(CodeWriter writer) + { + var nullable = TypeIdentity.Create().MakeNullable(); + writer.WriteType(nullable); + } + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(TypeIdentity)] }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PreferNullableContextOverloadAnalyzer.Rule.Id); + } + + [Test] + public async Task Nullable_BareCall_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + TypeReference reference = TypeIdentity.Create().AsTypeReference(); + var nullable = reference.Nullable(); + } + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(TypeIdentity)] }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(PreferNullableContextOverloadAnalyzer.Rule.Id); + } + + [Test] + public async Task MakeNullable_WithContextArgument_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit(CodeWriter writer) + { + var nullable = TypeIdentity.Create().MakeNullable(writer); + writer.WriteType(nullable); + } + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = [typeof(TypeIdentity)] }, + cancellationToken + ); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task MakeNullable_OnUnrelatedType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + class Other + { + public string MakeNullable() => string.Empty; + } + + class Emitter + { + public void Emit() + { + var other = new Other(); + _ = other.MakeNullable(); + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferNullableContextOverloadCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferNullableContextOverloadCodeFixProviderTests.cs new file mode 100644 index 0000000..177702f --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferNullableContextOverloadCodeFixProviderTests.cs @@ -0,0 +1,120 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PreferNullableContextOverloadCodeFixProviderTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task MakeNullable_BareCall_PassesWriterArgument(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit(CodeWriter writer) + { + var nullable = TypeIdentity.Create().MakeNullable(); + writer.WriteType(nullable); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferNullableContextOverloadCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(TypeIdentity)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferNullableContextOverloadAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("MakeNullable(writer)"); + } + + [Test] + public async Task Nullable_BareCall_PassesSettingsArgument(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit(GenerationSettings settings) + { + TypeReference reference = TypeIdentity.Create().AsTypeReference(); + _ = reference.Nullable(); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferNullableContextOverloadCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(TypeIdentity)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferNullableContextOverloadAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("Nullable(settings)"); + } + + [Test] + public async Task FixAll_GivenMultipleDocuments_FixesEveryInstance(CancellationToken cancellationToken) + { + string[] sources = + [ + """ + using Purview.SourceGeneratorFramework; + + class EmitterA + { + public void Emit(CodeWriter writer) + { + var one = TypeIdentity.Create().MakeNullable(); + var two = TypeIdentity.Create().MakeNullable(); + writer.WriteType(one); + writer.WriteType(two); + } + } + """, + """ + using Purview.SourceGeneratorFramework; + + class EmitterB + { + public void Emit(CodeWriter writer) + { + var three = TypeIdentity.Create().MakeNullable(); + writer.WriteType(three); + } + } + """, + ]; + + var result = await ApplyFixAllAsync( + sources, + new CodeFixTestOptions + { + EquivalenceKey = PreferNullableContextOverloadCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(TypeIdentity)], + }, + cancellationToken + ); + + await Assert.That(result.Diagnostics).IsNotEmpty(); + foreach (var fixedSource in result.FixedSources.Values) + { + await Assert.That(fixedSource).DoesNotContain(".MakeNullable()"); + await Assert.That(fixedSource).Contains(".MakeNullable(writer)"); + } + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/LoggingRefactoringTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/LoggingRefactoringTests.cs new file mode 100644 index 0000000..8c23a19 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/LoggingRefactoringTests.cs @@ -0,0 +1,68 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.ExampleGenerator.CodeFixers; + +public class LoggingRefactoringTests : TUnitRefactoringTestBase +{ + [Test] + public async Task RefactorAsync_GivenNodeSelector_AddsDebugAttributeToMethod(CancellationToken cancellationToken) + { + const string source = """ + namespace Test; + + public class Worker + { + public void Process(int id, string name) { } + } + """; + + var result = await RefactorAsync( + source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("Process"), + EquivalenceKey = LoggingRefactoringProvider.EquivalenceKey, + }, + cancellationToken + ); + + await Assert.That(result.CodeActions).IsNotEmpty(); + + var fixedSource = result.FixedSources["Test1.cs"]; + await Assert.That(fixedSource).Contains("[Debug]"); + + var method = result.FixedCode().GetMethod("Process"); + await Assert.That(method.AttributeLists).IsNotEmpty(); + await Assert.That(method.AttributeLists[0].ToString()).Contains("Debug"); + } + + [Test] + public async Task RefactorAsync_FixedMethod_MatchesSignature(CancellationToken cancellationToken) + { + const string source = """ + namespace Test; + + public class Worker + { + public void Process(int id, string name) { } + } + """; + + var result = await RefactorAsync( + source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("Process"), + EquivalenceKey = LoggingRefactoringProvider.EquivalenceKey, + }, + cancellationToken + ); + + var intType = TypeReference.Create(); + var stringType = TypeReference.Create(); + + await Assert.That(result.FixedCode().HasMethod("Process", intType, stringType)).IsTrue(); + await Assert.That(result.FixedCode().HasMethod("Process", stringType)).IsFalse(); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests.csproj b/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests.csproj new file mode 100644 index 0000000..c529a51 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests/SourceGeneratorFramework.ExampleGenerator.CodeFixers.UnitTests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs new file mode 100644 index 0000000..c598cc2 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/LogAttributeDataTests.cs @@ -0,0 +1,94 @@ +using Purview.SourceGeneratorFramework.Examples; +using Purview.SourceGeneratorFramework.Generators; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.ExampleGenerator; + +public class LogAttributeDataTests + : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task Generate_InheritedAttributeMapping_DebugDefaultsToDebugLevel(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework.Examples; + using Purview.SourceGeneratorFramework.Generators; + + namespace Sample; + + [Generate(typeof(LogAttribute), MatchByInheritance = true)] + public readonly partial record struct LogAttributeData( + [Property] string? Message, + [Property] int EventId, + [Property] string? CategoryName, + [Property(DefaultValue = LogLevel.Information)] LogLevel Level + ); + + [Generate(typeof(DebugAttribute))] + public readonly partial record struct DebugAttributeData( + [NestedModel] LogAttributeData Log, + [Property(DefaultValue = LogLevel.Debug)] LogLevel Level + ); + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions().AssertNoLogErrors(); + + var debugGenerated = await GetGeneratedStringAsync( + result, + "DebugAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert.That(debugGenerated).IsNotNull(); + await Assert.That(debugGenerated).Contains("readonly partial record struct DebugAttributeData"); + await Assert.That(debugGenerated).Contains("global::Sample.LogAttributeData Log"); + await Assert + .That(debugGenerated) + .Contains("var log = global::Sample.LogAttributeData.FromAttributeData(attributeData);"); + await Assert + .That(debugGenerated) + .Contains( + "attributeData.GetNamedArgument(\"Level\", (global::Purview.SourceGeneratorFramework.Examples.LogLevel)1);" + ); + + var logGenerated = await GetGeneratedStringAsync( + result, + "LogAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert.That(logGenerated).IsNotNull(); + await Assert.That(logGenerated).Contains("InheritsFrom(attributeData.AttributeClass, TargetAttribute)"); + await Assert + .That(logGenerated) + .Contains( + "attributeData.GetNamedArgument(\"Level\", (global::Purview.SourceGeneratorFramework.Examples.LogLevel)2);" + ); + } + + static async Task GetGeneratedStringAsync( + DriverRunResult result, + string fileName, + CancellationToken cancellationToken + ) + { + var tree = result.GetGeneratedTree(fileName); + return tree is null ? null : (await tree.GetTextAsync(cancellationToken)).ToString(); + } +} + +public sealed record LogAttributeDataTestOptions : SourceGeneratorTestOptions +{ + public LogAttributeDataTestOptions() + { + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(TypeIdentity), + typeof(LogLevel), + typeof(LogAttribute), + typeof(DebugAttribute) + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationCacheTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationCacheTests.cs new file mode 100644 index 0000000..b714cf6 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationCacheTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.Examples; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +namespace Purview.SourceGeneratorFramework.ExampleGenerator; + +/// +/// Proves the pipeline caches correctly stage-by-stage. Other +/// generator projects should mirror this pattern using SourceGeneratorTestRunner.RunIncrementalAsync or +/// GenerateIncrementalAsync. +/// +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + { + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + } + + return builder.ToImmutable(); + } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [new IncrementalRunInput([Source])], + cancellationToken: cancellationToken + ); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(static r => r).All(static r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second).IsNotEmpty(); + + // The generator's own pipeline stages must all be cached or unchanged. (Roslyn's internal + // ForAttributeWithMetadataName steps can report Modified on rerun because the post-initialization + // attribute source is regenerated as a new tree.) + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + + await Assert + .That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(static r => r is StepReason.Cached or StepReason.Unchanged) + ) + ) + .IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached( + CancellationToken cancellationToken + ) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert + .That( + second["ForAttribute_GenerateServiceAttribute"] + .All(static r => r is StepReason.Cached or StepReason.Unchanged) + ) + .IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStageStaysCached( + CancellationToken cancellationToken + ) + { + const string changedSource = """ + namespace Test; + + [GenerateService(ServiceLifetime.Transient, Name = "Other")] + public class OtherService { } + """; + + var result = await GenerateIncrementalAsync( + [new IncrementalRunInput([Source]), new IncrementalRunInput([changedSource])], + cancellationToken: cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["ForAttribute_GenerateServiceAttribute"]).Contains(StepReason.Modified); + await Assert + .That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"].All(static r => r == StepReason.Cached)) + .IsTrue(); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs index 833c8b7..2cd5600 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/ServiceRegistrationGeneratorTests.cs @@ -1,12 +1,20 @@ using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Purview.SourceGeneratorFramework.Examples; +using Purview.SourceGeneratorFramework.Testing; using Purview.SourceGeneratorFramework.Testing.TUnit; +using Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; namespace Purview.SourceGeneratorFramework.ExampleGenerator; public class ServiceRegistrationGeneratorTests : TUnitSourceGeneratorTestBase { + static readonly TypeReference IServiceCollection = new( + new TypeIdentity("IServiceCollection", "Microsoft.Extensions.DependencyInjection") + ); + [Test] public async Task GenerateAsync_DefaultOptions_CapturesFrameworkLoggingThroughTUnitSink( CancellationToken cancellationToken @@ -51,27 +59,116 @@ public class OtherService { } var result = await GenerateAsync(source, cancellationToken); - var tree = result.GetGeneratedTree("ServiceCollectionExtensions.g.cs"); - var generated = tree is null ? null : (await tree.GetTextAsync(cancellationToken)).ToString(); + var query = result.Generated(); + var method = query.GetMethod("AddExampleServices"); + var @class = query.GetClass("ServiceCollectionExtensions"); - await Assert.That(generated).IsNotNull(); - await Assert.That(generated).Contains("public static class ServiceCollectionExtensions"); - await Assert - .That(generated) - .Contains( - "public static global::Microsoft.Extensions.DependencyInjection.IServiceCollection AddExampleServices" - ); + await Assert.That(@class.Identifier.ValueText).IsEqualTo("ServiceCollectionExtensions"); + await Assert.That(@class.Modifiers.Any(static m => m.IsKind(SyntaxKind.StaticKeyword))).IsTrue(); + await Assert.That(method.Identifier.ValueText).IsEqualTo("AddExampleServices"); + await Assert.That(method.HasParameters(query, IServiceCollection)).IsTrue(); + + var methodText = method.ToString(); await Assert - .That(generated) + .That(methodText) .Contains( "global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddSingleton(services);" ); await Assert - .That(generated) + .That(methodText) .Contains( "global::Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions.AddScoped(services);" ); - await Assert.That(generated).Contains("// Service name: NamedService"); + await Assert.That(methodText).Contains("// Service name: NamedService"); + } + + [Test] + public async Task GenerateService_MethodSignature_MatchesThisParameter(CancellationToken cancellationToken) + { + var source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + var result = await GenerateAsync(source, cancellationToken); + + await Assert.That(result.Generated().HasMethod("AddExampleServices", IServiceCollection)).IsTrue(); + await Assert.That(result.Generated().HasReturnType("AddExampleServices", IServiceCollection)).IsTrue(); + } + + [Test] + public async Task GenerateService_GivenNullableDisabled_OmitsNullableDirective(CancellationToken cancellationToken) + { + var source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + var result = await GenerateAsync( + source, + new ServiceRegistrationTestOptions { NullableContextOptions = NullableContextOptions.Disable }, + cancellationToken + ); + + var generated = ( + await result.Generated().GetSyntaxTree("ServiceCollectionExtensions.g.cs").GetTextAsync(cancellationToken) + ).ToString(); + + await Assert.That(generated).DoesNotContain("#nullable enable"); + } + + [Test] + public async Task GenerateService_GivenNullableEnabled_WritesNullableDirective(CancellationToken cancellationToken) + { + var source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + var result = await GenerateAsync( + source, + new ServiceRegistrationTestOptions { NullableContextOptions = NullableContextOptions.Enable }, + cancellationToken + ); + + var generated = ( + await result.Generated().GetSyntaxTree("ServiceCollectionExtensions.g.cs").GetTextAsync(cancellationToken) + ).ToString(); + + await Assert.That(generated).Contains("#nullable enable"); + } + + [Test] + public async Task GenerateService_GivenNullableDisabled_PostInitializationOutputKeepsAnnotation( + CancellationToken cancellationToken + ) + { + var source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + var result = await GenerateAsync( + source, + new ServiceRegistrationTestOptions { NullableContextOptions = NullableContextOptions.Disable }, + cancellationToken + ); + + // Post-initialization outputs have no compilation context, so the unknown nullable state falls back to + // keeping the annotation and emitting the #nullable enable directive. + var attribute = result.Generated().GetClass("GenerateServiceAttribute"); + await Assert.That(attribute.AttributeLists).IsNotEmpty(); + + var nameProperty = result.Generated().GetProperty("Name"); + await Assert.That(nameProperty.Type.ToString()).IsEqualTo("string?"); } [Test] @@ -93,8 +190,7 @@ public class MyService { } cancellationToken ); - var tree = result.GetGeneratedTree("ServiceCollectionExtensions.g.cs"); - await Assert.That(tree).IsNull(); + await Assert.That(result.Generated().HasSyntaxTree("ServiceCollectionExtensions.g.cs")).IsFalse(); } [Test] @@ -122,17 +218,17 @@ public class MyService { } cancellationToken ); - var tree = result.GetGeneratedTree("ServiceInfo.g.cs"); - var generated = tree is null ? null : (await tree.GetTextAsync(cancellationToken)).ToString(); + var query = result.Generated(); + var serviceInfoTree = query.GetSyntaxTree("ServiceInfo.g.cs"); + var serviceInfoQuery = new CodeQuery([serviceInfoTree], result.CompilationResult.Compilation); - await Assert.That(generated).IsNotNull(); - await Assert.That(generated).Contains("public static class ServiceInfo"); - await Assert.That(generated).Contains("public static class MyService"); - await Assert.That(generated).Contains("public static string Name => \"MyService\";"); - await Assert.That(generated).Contains("public static string Lifetime => \"Transient\";"); + await Assert.That(query.GetClass("ServiceInfo").Identifier.ValueText).IsEqualTo("ServiceInfo"); + await Assert.That(serviceInfoQuery.GetClass("MyService").Identifier.ValueText).IsEqualTo("MyService"); + await Assert.That(serviceInfoQuery.GetProperty("Name").ExpressionBody!.ToString()).Contains("MyService"); + await Assert.That(serviceInfoQuery.GetProperty("Lifetime").ExpressionBody!.ToString()).Contains("Transient"); await Assert - .That(generated) - .Contains("public static global::System.Type Type => typeof(global::Test.MyService);"); + .That(serviceInfoQuery.GetProperty("Type").ExpressionBody!.ToString()) + .Contains("typeof(global::Test.MyService)"); } [Test] @@ -147,18 +243,42 @@ public class MyService { } var result = await GenerateAsync(source, cancellationToken); - var attributeTree = result.GetGeneratedTree("GenerateServiceAttribute.g.cs"); - var attributeSource = attributeTree is null - ? null - : (await attributeTree.GetTextAsync(cancellationToken)).ToString(); + var query = result.Generated(); + var attribute = query.GetClass("GenerateServiceAttribute"); + var lifetime = query.GetEnum("ServiceLifetime"); - await Assert.That(attributeSource).IsNotNull(); - await Assert.That(attributeSource).Contains("public enum ServiceLifetime"); - await Assert - .That(attributeSource) - .Contains("public sealed class GenerateServiceAttribute : global::System.Attribute"); - await Assert - .That(attributeSource) - .Contains("public global::Purview.SourceGeneratorFramework.Examples.ServiceLifetime Lifetime { get; }"); + await Assert.That(attribute.BaseList!.ToString()).Contains("global::System.Attribute"); + await Assert.That(lifetime.Members.Count).IsEqualTo(3); + await Assert.That(query.GetProperty("Lifetime").Type.ToString()).Contains("ServiceLifetime"); + } + + [Test] + public async Task GenerateService_AssertionExtensions_ReturnSyntaxNodes(CancellationToken cancellationToken) + { + var source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + var result = await GenerateAsync(source, cancellationToken); + + var method = await Assert.That(result).HasGeneratedMethod("AddExampleServices"); + await Assert.That(method.Identifier.ValueText).IsEqualTo("AddExampleServices"); + + var @class = await Assert.That(result).HasGeneratedClass("ServiceCollectionExtensions"); + await Assert.That(@class.Identifier.ValueText).IsEqualTo("ServiceCollectionExtensions"); + + var attribute = await Assert.That(result).HasGeneratedClass("GenerateServiceAttribute"); + await Assert.That(attribute.BaseList!.ToString()).Contains("global::System.Attribute"); + + var nameProperty = await Assert.That(result).HasGeneratedProperty("Name"); + await Assert.That(nameProperty.Identifier.ValueText).IsEqualTo("Name"); + + var lifetime = await Assert.That(result).HasGeneratedMethod("AddExampleServices", [IServiceCollection]); + await Assert.That(lifetime.ParameterList.Parameters.Count).IsEqualTo(1); + + await Assert.That(result).HasGeneratedSyntaxTree("ServiceCollectionExtensions.g.cs"); } } diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj index 049c49d..84f85de 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/SourceGeneratorFramework.ExampleGenerator.UnitTests.csproj @@ -1,5 +1,6 @@  + diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs index f4d60ce..be2aa8c 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs @@ -855,6 +855,45 @@ public MyAttribute(MyEnum value) { } await Assert.That(generated).Contains("var value = __valueTc.ToEnumString() ?? \"Test.MyEnum.B\";"); } + [Test] + public async Task Generate_EnumLiteralDefaultValue_RendersQualifiedCast(CancellationToken cancellationToken) + { + var source = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test + { + public enum LogLevel { Trace, Debug, Information } + + [Generate(typeof(MyAttribute))] + public readonly partial record struct MyAttributeData( + [Property(DefaultValue = LogLevel.Information)] LogLevel Level + ); + + public class MyAttribute : System.Attribute + { + public LogLevel Level { get; set; } + } + } + """; + + var result = await GenerateAsync(source, cancellationToken: cancellationToken); + + result.AssertNoGenerationExceptions().AssertNoLogErrors(); + + var generated = await GetGeneratedStringAsync( + result, + "MyAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + + await Assert.That(generated).IsNotNull(); + await Assert + .That(generated) + .Contains("attributeData.GetNamedArgument(\"Level\", (global::Test.LogLevel)2);"); + await Assert.That(generated).DoesNotContain("global::global::"); + } + static async Task GetGeneratedStringAsync( DriverRunResult result, string fileName, diff --git a/src/tests/SourceGeneratorFramework.UnitTests/AddObsoleteRefactoringProvider.cs b/src/tests/SourceGeneratorFramework.UnitTests/AddObsoleteRefactoringProvider.cs new file mode 100644 index 0000000..23f4143 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/AddObsoleteRefactoringProvider.cs @@ -0,0 +1,46 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeRefactorings; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Test refactoring that adds a [System.Obsolete] attribute to the method the cursor is on. +/// +[ExportCodeRefactoringProvider(LanguageNames.CSharp, Name = nameof(AddObsoleteRefactoringProvider))] +public sealed class AddObsoleteRefactoringProvider : CodeRefactoringProvider +{ + public const string EquivalenceKey = "AddObsolete"; + + public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken); + var method = root?.FindNode(context.Span).FirstAncestorOrSelf(); + if (method is null) + return; + + context.RegisterRefactoring( + CodeAction.Create( + "Add [Obsolete]", + cancellationToken => AddObsoleteAsync(context.Document, method, cancellationToken), + EquivalenceKey + ) + ); + } + + static async Task AddObsoleteAsync( + Document document, + MethodDeclarationSyntax method, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken); + var attribute = SyntaxFactory.AttributeList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Attribute(SyntaxFactory.ParseName("System.Obsolete"))) + ); + + return document.WithSyntaxRoot(root!.ReplaceNode(method, method.AddAttributeLists(attribute))); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryAssertionTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryAssertionTests.cs new file mode 100644 index 0000000..dabe397 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryAssertionTests.cs @@ -0,0 +1,82 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Testing.TUnit.Assertions; + +namespace Purview.SourceGeneratorFramework; + +public class CodeQueryAssertionTests +{ + sealed class SimpleGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(static output => + output.AddSource( + "Simple.g.cs", + """ + namespace Generated; + + public static class Simple + { + public const string Name = "simple"; + public static int Count { get; set; } + + public static void DoWork(int value, int? optional, object? context) { } + } + """ + ) + ); + } + } + + [Test] + public async Task HasGeneratedMethod_ReturnsTheMethodNode(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + var result = await runner.RunAsync("public sealed class Input { }", cancellationToken: cancellationToken); + + var method = await Assert.That(result).HasGeneratedMethod("DoWork"); + + await Assert.That(method).IsNotNull(); + await Assert.That(method.Identifier.ValueText).IsEqualTo("DoWork"); + } + + [Test] + public async Task HasGeneratedMethod_WithParameterTypes_ReturnsMatchingMethod(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + var result = await runner.RunAsync("public sealed class Input { }", cancellationToken: cancellationToken); + + TypeReference[] parameters = + [ + TypeReference.Create(), + TypeReference.Create().Nullable(), + TypeReference.Create().Nullable(), + ]; + var method = await Assert.That(result).HasGeneratedMethod("DoWork", parameters); + + await Assert.That(method.ParameterList.Parameters.Count).IsEqualTo(3); + } + + [Test] + public async Task HasGeneratedClass_ReturnsTheClassNode(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + var result = await runner.RunAsync("public sealed class Input { }", cancellationToken: cancellationToken); + + var @class = await Assert.That(result).HasGeneratedClass("Simple"); + + await Assert.That(@class.Identifier.ValueText).IsEqualTo("Simple"); + await Assert.That(@class.Members).IsNotEmpty(); + } + + [Test] + public async Task HasGeneratedField_ReturnsTheFieldNode(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + var result = await runner.RunAsync("public sealed class Input { }", cancellationToken: cancellationToken); + + var field = await Assert.That(result).HasGeneratedField("Name"); + + await Assert.That(field.Declaration.Variables[0].Identifier.ValueText).IsEqualTo("Name"); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs new file mode 100644 index 0000000..74b6af8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/CodeQueryTests.cs @@ -0,0 +1,207 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework; + +public class CodeQueryTests +{ + const string Source = """ + namespace Test; + + public class ComplexType { } + + public sealed class Sample + { + public const string Constant = "value"; + public int Count { get; set; } + + public void DoWork(int value, int? optional, ComplexType complex) { } + public string Name { get; set; } = ""; + public int Compute(int left, int right) => left + right; + public string Format(string format, object? value) => ""; + } + + public interface IContract { } + public enum Level { None, Low, High } + public delegate void Handler(int value); + public record Person(string Name); + """; + + static CodeQuery CreateQuery() + { + var (compilation, _) = TestCompilation.CreateWithRoot(Source); + + return new([.. compilation.SyntaxTrees], compilation); + } + + [Test] + public async Task GetMethod_FindsMethodByName() + { + var query = CreateQuery(); + + var method = query.GetMethod("DoWork"); + + await Assert.That(method.Identifier.ValueText).IsEqualTo("DoWork"); + } + + [Test] + public async Task HasMethod_GivenPresentAndAbsent_ReturnsTrueFalse() + { + var query = CreateQuery(); + + await Assert.That(query.HasMethod("DoWork")).IsTrue(); + await Assert.That(query.HasMethod("Missing")).IsFalse(); + } + + [Test] + public async Task TryGetMethod_GivenPresent_ReturnsTrueAndNode() + { + var query = CreateQuery(); + + await Assert.That(query.TryGetMethod("Compute", out var method)).IsTrue(); + await Assert.That(method).IsNotNull(); + } + + [Test] + public async Task GetMethod_GivenAbsent_ThrowsSyntaxNotFoundException() + { + var query = CreateQuery(); + + await Assert + .That(() => query.GetMethod("Missing")) + .Throws() + .WithMessageContaining("Missing", StringComparison.Ordinal); + } + + [Test] + public async Task GetMethod_WithParameterTypes_MatchesSignature() + { + var query = CreateQuery(); + + var intType = TypeReference.Create(); + var nullableInt = TypeReference.Create().Nullable(); + var complexType = new TypeReference(new TypeIdentity("ComplexType", "Test")); + + var method = query.GetMethod("DoWork", intType, nullableInt, complexType); + await Assert.That(method.Identifier.ValueText).IsEqualTo("DoWork"); + } + + [Test] + public async Task HasMethod_WithParameterTypes_EnforcesNullableValueTypes() + { + var query = CreateQuery(); + + var intType = TypeReference.Create(); + var nullableInt = TypeReference.Create().Nullable(); + + // int? parameter must not match a plain int reference and vice versa. + await Assert.That(query.HasMethod("DoWork", intType, intType, intType)).IsFalse(); + await Assert.That(query.HasMethod("DoWork", nullableInt, nullableInt, nullableInt)).IsFalse(); + } + + [Test] + public async Task GetMethod_WithReturnType_Matches() + { + var query = CreateQuery(); + + await Assert.That(query.HasReturnType("Compute", TypeReference.Create())).IsTrue(); + await Assert.That(query.HasReturnType("Compute", TypeReference.Create())).IsFalse(); + } + + [Test] + public async Task HasParameters_OnNode_MatchesSignature() + { + var query = CreateQuery(); + var method = query.GetMethod("Format"); + var stringType = TypeReference.Create(); + var objectType = TypeReference.Create().Nullable(); + + await Assert.That(method.HasParameters(query, stringType, objectType)).IsTrue(); + await Assert.That(method.HasParameters(query, stringType)).IsFalse(); + } + + [Test] + public async Task GetClass_GetStruct_GetInterface_GetEnum_GetDelegate_GetRecord_FindDeclarations() + { + var query = CreateQuery(); + + await Assert.That(query.GetClass("Sample").Identifier.ValueText).IsEqualTo("Sample"); + await Assert.That(query.GetInterface("IContract").Identifier.ValueText).IsEqualTo("IContract"); + await Assert.That(query.GetEnum("Level").Identifier.ValueText).IsEqualTo("Level"); + await Assert.That(query.GetDelegate("Handler").Identifier.ValueText).IsEqualTo("Handler"); + await Assert.That(query.GetRecord("Person").Identifier.ValueText).IsEqualTo("Person"); + await Assert.That(query.HasClass("Missing")).IsFalse(); + await Assert.That(query.HasInterface("IContract")).IsTrue(); + } + + [Test] + public async Task GetProperty_GetField_FindMembers() + { + var query = CreateQuery(); + + await Assert.That(query.GetProperty("Count").Identifier.ValueText).IsEqualTo("Count"); + await Assert.That(query.HasProperty("Name")).IsTrue(); + await Assert + .That(query.GetField("Constant").Declaration.Variables[0].Identifier.ValueText) + .IsEqualTo("Constant"); + await Assert.That(query.HasField("Constant")).IsTrue(); + await Assert.That(query.HasField("Missing")).IsFalse(); + } + + [Test] + public async Task GetTypeDeclaration_MatchesAnyDeclarationKind() + { + var query = CreateQuery(); + + await Assert.That(query.HasTypeDeclaration("Sample")).IsTrue(); + await Assert.That(query.HasTypeDeclaration("IContract")).IsTrue(); + await Assert.That(query.HasTypeDeclaration("Person")).IsTrue(); + await Assert.That(query.HasTypeDeclaration("Missing")).IsFalse(); + } + + [Test] + public async Task GetNamespace_FindsDottedNamespace() + { + var query = CreateQuery(); + + await Assert.That(query.HasNamespace("Test")).IsTrue(); + await Assert.That(query.HasNamespace("Other")).IsFalse(); + } + + [Test] + public async Task GenericGet_And_Has_FindSyntaxByPredicate() + { + var query = CreateQuery(); + + await Assert + .That(query.Has(method => method.Identifier.ValueText == "DoWork")) + .IsTrue(); + await Assert + .That(query.Has(method => method.Identifier.ValueText == "Missing")) + .IsFalse(); + await Assert + .That(query.Get(method => method.Identifier.ValueText == "Compute") is not null) + .IsTrue(); + } + + [Test] + public async Task TryGetSyntaxTree_MatchesBySuffix() + { + var tree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(Source, path: "Generated/File.g.cs"); + var query = new CodeQuery([tree]); + + await Assert.That(query.HasSyntaxTree("File.g.cs")).IsTrue(); + await Assert.That(query.HasSyntaxTree("Other.g.cs")).IsFalse(); + await Assert.That(query.TryGetSyntaxTree("File.g.cs", out var found)).IsTrue(); + await Assert.That(ReferenceEquals(found, tree)).IsTrue(); + } + + [Test] + public async Task Get_WhenNothingMatches_ThrowsSyntaxNotFoundException() + { + var query = CreateQuery(); + + await Assert + .That(() => query.Get(static method => method.Identifier.ValueText == "Nope")) + .Throws(); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/MemberQueryTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/MemberQueryTests.cs new file mode 100644 index 0000000..04f703f --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/MemberQueryTests.cs @@ -0,0 +1,111 @@ +namespace Purview.SourceGeneratorFramework; + +public class MemberQueryTests +{ + const string Source = """ + namespace Test; + + public class ComplexType { } + + public sealed class Sample + { + public Sample() { } + public Sample(int id) { } + + public int Count { get; set; } + public string Name { get; set; } = ""; + + public string this[int index] => "value"; + + public void DoWork(int value, int? optional, ComplexType complex) { } + public int Compute(int left, int right) => left + right; + public string Format(string format, object? value) => ""; + } + """; + + static CodeQuery CreateQuery() + { + var (compilation, _) = TestCompilation.CreateWithRoot(Source); + + return new([.. compilation.SyntaxTrees], compilation); + } + + static readonly TypeReference IntType = TypeReference.Create(); + static readonly TypeReference StringType = TypeReference.Create(); + static readonly TypeReference NullableIntType = TypeReference.Create().Nullable(); + static readonly TypeReference ComplexType = new(new TypeIdentity("ComplexType", "Test")); + + [Test] + public async Task Class_HasMethod_MatchesParameterTypes() + { + var query = CreateQuery(); + var cls = query.GetClass("Sample"); + + await Assert.That(cls.HasMethod(query, "DoWork", IntType, NullableIntType, ComplexType)).IsTrue(); + await Assert.That(cls.HasMethod(query, "DoWork", IntType, IntType)).IsFalse(); + await Assert.That(cls.HasMethod(query, "Missing")).IsFalse(); + await Assert.That(cls.GetMethod(query, "Compute").HasParameters(query, IntType, IntType)).IsTrue(); + } + + [Test] + public async Task Class_HasMethodReturnType_Matches() + { + var query = CreateQuery(); + var cls = query.GetClass("Sample"); + + await Assert.That(cls.HasMethodReturnType(query, "Compute", IntType)).IsTrue(); + await Assert.That(cls.HasMethodReturnType(query, "Compute", StringType)).IsFalse(); + await Assert.That(cls.GetMethod(query, "Compute").HasReturnType(query, IntType)).IsTrue(); + } + + [Test] + public async Task Class_HasProperty_MatchesType() + { + var query = CreateQuery(); + var cls = query.GetClass("Sample"); + + await Assert.That(cls.HasProperty(query, "Count")).IsTrue(); + await Assert.That(cls.HasProperty(query, "Count", IntType)).IsTrue(); + await Assert.That(cls.HasProperty(query, "Count", StringType)).IsFalse(); + await Assert.That(cls.GetProperty(query, "Name").HasType(query, StringType)).IsTrue(); + } + + [Test] + public async Task Class_HasIndexer_MatchesTypeAndIndexParameters() + { + var query = CreateQuery(); + var cls = query.GetClass("Sample"); + + await Assert.That(cls.HasIndexer(query)).IsTrue(); + await Assert.That(cls.HasIndexer(query, StringType)).IsTrue(); + await Assert.That(cls.HasIndexer(query, StringType, IntType)).IsTrue(); + await Assert.That(cls.HasIndexer(query, IntType, IntType)).IsFalse(); + await Assert.That(cls.GetIndexer(query).HasType(query, StringType)).IsTrue(); + } + + [Test] + public async Task Class_HasConstructor_MatchesParameterTypes() + { + var query = CreateQuery(); + var cls = query.GetClass("Sample"); + + await Assert.That(cls.HasConstructor(query)).IsTrue(); + await Assert.That(cls.HasConstructor(query, IntType)).IsTrue(); + await Assert.That(cls.HasConstructor(query, StringType)).IsFalse(); + await Assert.That(cls.GetConstructor(query, IntType).ParameterList.Parameters.Count).IsEqualTo(1); + } + + [Test] + public async Task GetClass_GivenNamespace_FiltersByNamespace() + { + var query = CreateQuery(); + + await Assert.That(query.HasClass("Sample", "Test")).IsTrue(); + await Assert.That(query.HasClass("Sample", "Other")).IsFalse(); + await Assert.That(query.HasClass("Sample")).IsTrue(); + await Assert + .That(() => query.GetClass("Sample", "Other")) + .Throws() + .WithMessageContaining("Other", StringComparison.Ordinal); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/RefactoringTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/RefactoringTests.cs new file mode 100644 index 0000000..e206e43 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/RefactoringTests.cs @@ -0,0 +1,81 @@ +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework; + +public class RefactoringTests : TUnitRefactoringTestBase +{ + const string Source = """ + namespace Test; + + public class Sample + { + public void DoWork(int value) { } + } + """; + + [Test] + public async Task RefactorAsync_GivenNodeSelector_AddsObsoleteToMethod(CancellationToken cancellationToken) + { + var result = await RefactorAsync( + Source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("DoWork"), + EquivalenceKey = AddObsoleteRefactoringProvider.EquivalenceKey, + }, + cancellationToken + ); + + await Assert.That(result.CodeActions).IsNotEmpty(); + + var fixedSource = result.FixedSources["Test1.cs"]; + await Assert.That(fixedSource).Contains("[System.Obsolete]"); + await Assert.That(fixedSource).Contains("public void DoWork(int value)"); + } + + [Test] + public async Task RefactorAsync_GivenSpanTrigger_AppliesRefactoring(CancellationToken cancellationToken) + { + var tree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText( + Source, + cancellationToken: cancellationToken + ); + var method = (await tree.GetRootAsync(cancellationToken)) + .DescendantNodes() + .OfType() + .First(static candidate => candidate.Identifier.ValueText == "DoWork"); + + var result = await RefactorAsync( + Source, + new RefactorTestOptions + { + IncludeDefaultNamespaces = false, + Span = method.Identifier.Span, + EquivalenceKey = AddObsoleteRefactoringProvider.EquivalenceKey, + }, + cancellationToken + ); + + await Assert.That(result.FixedSources["Test1.cs"]).Contains("[System.Obsolete]"); + } + + [Test] + public async Task RefactorAsync_FixedCodeQuery_LocatesRefactoredMethod(CancellationToken cancellationToken) + { + var result = await RefactorAsync( + Source, + new RefactorTestOptions + { + NodeSelector = query => query.GetMethod("DoWork"), + EquivalenceKey = AddObsoleteRefactoringProvider.EquivalenceKey, + }, + cancellationToken + ); + + var method = result.FixedCode().GetMethod("DoWork"); + + await Assert.That(method.AttributeLists).IsNotEmpty(); + await Assert.That(method.AttributeLists[0].ToString()).Contains("Obsolete"); + } +} diff --git a/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj b/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj index caaecd0..1381f50 100644 --- a/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj +++ b/src/tests/SourceGeneratorFramework.UnitTests/SourceGeneratorFramework.UnitTests.csproj @@ -1,10 +1,13 @@  + + + diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index fb21a34..d680b2a 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -1787,6 +1787,170 @@ public async Task WriteAutoGeneratedHeader_WritesHeader() await Assert.That(result).DoesNotContain("// Generated at "); } + [Test] + public async Task WriteAutoGeneratedHeader_GivenDefaultSettings_WritesNullableEnableDirective() + { + var writer = CodeWriterFactory.ForTests(); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).Contains("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenNullableDirectiveDisable_OmitsDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).DoesNotContain("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenNullableDirectiveAlways_WritesDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Always, + } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).Contains("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenAutoAndNullableEnabled_WritesDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = true } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).Contains("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenAutoAndNullableDisabled_OmitsDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = false } + ); + + writer.WriteAutoGeneratedHeader(); + + await Assert.That(writer.ToString()).DoesNotContain("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenParameterOverride_OverridesSettings() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + } + ); + + writer.WriteAutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Always); + + await Assert.That(writer.ToString()).Contains("#nullable enable"); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenDisabledDirective_WritesExactHeaderWithoutDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + } + ); + + writer.WriteAutoGeneratedHeader("TestGenerator", "1.0"); + + await Assert + .That(writer.ToString()) + .IsEqualTo( + "// \n" + + "// This code was generated by TestGenerator (version 1.0).\n" + + "// Changes to this file will be lost when the source generator runs again.\n" + + "\n" + ); + } + + [Test] + public async Task WriteAutoGeneratedHeader_GivenEnabledDirective_WritesExactHeaderWithDirective() + { + var writer = CodeWriterFactory.ForTests( + settings: new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Always, + } + ); + + writer.WriteAutoGeneratedHeader("TestGenerator", "1.0"); + + await Assert + .That(writer.ToString()) + .IsEqualTo( + "// \n" + + "// This code was generated by TestGenerator (version 1.0).\n" + + "// Changes to this file will be lost when the source generator runs again.\n" + + "\n" + + "#nullable enable\n" + + "\n" + ); + } + + [Test] + public async Task WriteType_GivenNullableDisabledContext_StripsReferenceAnnotations() + { + var writer = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = false } + ); + + writer.WriteType(TypeIdentity.Create().MakeNullable()); + writer.Write(" "); + writer.WriteType(TypeIdentity.Create().MakeNullable()); + writer.Write(" "); + writer.WriteType(TypeIdentity.Create().MakeNullable().MakeArray()); + + await Assert.That(writer.ToString()).IsEqualTo("string int? string[]"); + } + + [Test] + public async Task WriteType_GivenNullableEnabledOrUnknownContext_KeepsAnnotations() + { + var enabled = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = true } + ); + var unknown = new CodeWriter(new GenerationSettings("TestGenerator", "1.0.0")); + + enabled.WriteType(TypeIdentity.Create().MakeNullable()); + unknown.WriteType(TypeIdentity.Create().MakeNullable()); + + await Assert.That(enabled.ToString()).IsEqualTo("string?"); + await Assert.That(unknown.ToString()).IsEqualTo("string?"); + } + + [Test] + public async Task WriteType_GivenNullReference_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert.That(() => writer.WriteType(null!)).Throws(); + } + [Test] public async Task GeneratorIdentity_GivenNoHeaderArguments_UsesDefaultsAndDecoratesDeclarations() { diff --git a/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs b/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs index 68f5746..b7cfb51 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/GenerationContextTests.cs @@ -31,6 +31,23 @@ public async Task CreateCodeWriter_GivenGeneratorIdentity_PropagatesIdentity() await Assert.That(writer.GeneratorVersion).IsEqualTo("2.3.4"); } + [Test] + public async Task CreateCodeWriter_GivenNullableDirectiveSettings_PropagatesToWriter() + { + var context = CreateGenerationContext( + new GenerationSettings("TestGenerator", "1.0.0") + { + NullableDirectiveMode = NullableDirectiveMode.Disable, + IsNullableContextEnabled = true, + } + ); + + var writer = context.CreateCodeWriter(); + + await Assert.That(writer.NullableDirectiveMode).IsEqualTo(NullableDirectiveMode.Disable); + await Assert.That(writer.IsNullableContextEnabled).IsTrue(); + } + [Test] public async Task Constructor_GivenLogger_ExposesLogger() { diff --git a/src/tests/SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests.cs b/src/tests/SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests.cs new file mode 100644 index 0000000..91c681a --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/IncrementalPipelineCacheTests.cs @@ -0,0 +1,147 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +namespace Purview.SourceGeneratorFramework; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + { + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + } + + return builder.ToImmutable(); + } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + + var result = await runner.RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken + ); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert + .That(reasons.Values.SelectMany(static reasons => reasons).All(static r => r == StepReason.New)) + .IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var runner = new SourceGeneratorTestRunner(); + + var result = await runner.RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var first = StepReasons(result.Runs[0]); + var second = StepReasons(result.Runs[1]); + + await Assert.That(second).IsNotEmpty(); + await Assert + .That( + second + .Values.SelectMany(static reasons => reasons) + .All(static r => r is StepReason.Cached or StepReason.Unchanged) + ) + .IsTrue(); + await Assert.That(first["ForAttribute_TestAttribute"].All(static r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached( + CancellationToken cancellationToken + ) + { + var runner = new SourceGeneratorTestRunner(); + + var result = await runner.RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert + .That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(static r => r == StepReason.Cached)) + .IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached( + CancellationToken cancellationToken + ) + { + var runner = new SourceGeneratorTestRunner(); + + var result = await runner.RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(static r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task ConfigChange_MarksConfigurationStagesModified_AttributeStageStaysCached( + CancellationToken cancellationToken + ) + { + var runner = new SourceGeneratorTestRunner(); + + var result = await runner.RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput( + [AttributedSource], + [(SourceGeneratorBuildProperties.ValidateCodeWriterScopes, "false")] + ), + ], + CreateOptions(), + cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["GetGenerationConfiguration"]).Contains(StepReason.Modified); + await Assert.That(second["GetGenerationContext_EmptyCapabilities"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(static r => r == StepReason.Cached)).IsTrue(); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs index ac5e6b4..58182a0 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/TypeReferenceTests.cs @@ -33,6 +33,214 @@ public async Task RenderFullName_GivenTypeParameterOrDynamic_RendersCore() await Assert.That(TypeReference.Dynamic.RenderFullName).IsEqualTo("dynamic"); } + // --------------------------------------------------------------------------------------------- + // Nullable-aware rendering + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task RenderFullNameForNullable_GivenDisabledContext_StripsReferenceAnnotationsOnly() + { + var @string = TypeIdentity.Create(); + var @int = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable().RenderFullNameForNullable(false)).IsEqualTo("string"); + await Assert.That(@string.MakeNullable().RenderFullNameForNullable(true)).IsEqualTo("string?"); + await Assert.That(@int.MakeNullable().RenderFullNameForNullable(false)).IsEqualTo("int?"); + await Assert.That(@int.MakeNullable().RenderFullNameForNullable(true)).IsEqualTo("int?"); + } + + [Test] + public async Task RenderFullNameForNullable_GivenArrayAnnotations_StripsThem() + { + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable().MakeArray().RenderFullNameForNullable(false)).IsEqualTo("string[]"); + await Assert + .That(@string.MakeNullable().MakeArray().Nullable().RenderFullNameForNullable(false)) + .IsEqualTo("string[]"); + await Assert.That(@string.MakeArray().Nullable().RenderFullNameForNullable(false)).IsEqualTo("string[]"); + } + + [Test] + public async Task RenderFullNameForNullable_GivenValueTypeArray_KeepsNullableElement() + { + var @int = TypeIdentity.Create(); + + await Assert.That(@int.MakeNullable().MakeArray().RenderFullNameForNullable(false)).IsEqualTo("int?[]"); + } + + [Test] + public async Task RenderFullNameForNullable_GivenUnknownType_KeepsAnnotation() + { + var unknown = new TypeIdentity("MyClass", "Sample").MakeNullable(); + + await Assert.That(unknown.RenderFullNameForNullable(false)).IsEqualTo("global::Sample.MyClass?"); + } + + [Test] + public async Task RenderFullNameForNullable_ThreadsThroughGenericArguments() + { + var list = new TypeIdentity(typeof(List<>)).MakeGeneric(TypeIdentity.Create().MakeNullable()); + var reference = list.AsTypeReference(); + + await Assert + .That(reference.RenderFullNameForNullable(true)) + .IsEqualTo("global::System.Collections.Generic.List"); + await Assert + .That(reference.RenderFullNameForNullable(false)) + .IsEqualTo("global::System.Collections.Generic.List"); + } + + // --------------------------------------------------------------------------------------------- + // Conditional nullable composition + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Nullable_GivenDisabledSettings_DoesNotAppendAnnotation() + { + var settings = new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = false }; + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable(settings).RenderFullName).IsEqualTo("string"); + await Assert.That(@string.MakeNullable(settings).IsNullable).IsFalse(); + } + + [Test] + public async Task Nullable_GivenEnabledOrUnknownSettings_AppendsAnnotation() + { + var enabled = new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = true }; + var unknown = new GenerationSettings("TestGenerator", "1.0.0"); + + await Assert.That(TypeIdentity.Create().MakeNullable(enabled).RenderFullName).IsEqualTo("string?"); + await Assert.That(TypeIdentity.Create().MakeNullable(unknown).RenderFullName).IsEqualTo("string?"); + } + + [Test] + public async Task Nullable_GivenWriterContext_BehavesLikeSettings() + { + var disabled = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = false } + ); + var enabled = new CodeWriter( + new GenerationSettings("TestGenerator", "1.0.0") { IsNullableContextEnabled = true } + ); + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable(disabled).RenderFullName).IsEqualTo("string"); + await Assert.That(@string.MakeNullable(enabled).RenderFullName).IsEqualTo("string?"); + } + + [Test] + public async Task Nullable_GivenNullSettingsOrWriter_Throws() + { + var @string = TypeIdentity.Create(); + + await Assert.That(() => @string.MakeNullable((GenerationSettings)null!)).Throws(); + await Assert.That(() => @string.MakeNullable((CodeWriter)null!)).Throws(); + } + + // --------------------------------------------------------------------------------------------- + // Similarity (nullable reference annotations are metadata) + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task Similar_IgnoresNullableReferenceAnnotations() + { + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable().Similar(@string.AsTypeReference())).IsTrue(); + await Assert.That(@string.AsTypeReference().Similar(@string.MakeNullable())).IsTrue(); + await Assert.That(TypeModifier.NullableReference).IsEqualTo(TypeModifier.Nullable); + } + + [Test] + public async Task Similar_KeepsNullableValueTypesSignificant() + { + var @int = TypeIdentity.Create(); + + await Assert.That(@int.MakeNullable().Similar(@int.AsTypeReference())).IsFalse(); + await Assert.That(@int.MakeNullable()).IsNotEqualTo(@int.AsTypeReference()); + } + + [Test] + public async Task Equality_RemainsStructural_GivenReferenceAnnotationDifference() + { + var @string = TypeIdentity.Create(); + + await Assert.That(@string.MakeNullable()).IsNotEqualTo(@string.AsTypeReference()); + await Assert.That(@string.MakeNullable() == @string.AsTypeReference()).IsFalse(); + } + + [Test] + public async Task Equality_MixedTypeIdentityAndTypeReference_ComparesStructurally() + { + var @string = TypeIdentity.Create(); + var plain = @string.AsTypeReference(); + var nullable = @string.AsTypeReference().Nullable(); + + await Assert.That(@string == plain).IsTrue(); + await Assert.That(plain == @string).IsTrue(); + await Assert.That(@string != nullable).IsTrue(); + await Assert.That(nullable != @string).IsTrue(); + await Assert.That(@string == nullable).IsFalse(); + await Assert.That(nullable == @string).IsFalse(); + } + + [Test] + public async Task Equality_MixedTypeIdentityAndTypeReference_NullableValueTypeIsSignificant() + { + var @int = TypeIdentity.Create(); + var nullableInt = @int.AsTypeReference().Nullable(); + + await Assert.That(@int == nullableInt).IsFalse(); + await Assert.That(nullableInt == @int).IsFalse(); + await Assert.That(@int != nullableInt).IsTrue(); + } + + [Test] + public async Task Similar_GivenNestedGeneric_DiffersOnlyByReferenceAnnotation_ReturnsTrue() + { + // IEnumerable> versus + // IEnumerable> — the annotation is metadata, so the two are similar. + var withObject = TypeIdentity.Create>>().AsTypeReference(); + var withNullableObject = new TypeIdentity(typeof(IEnumerable<>)) + .MakeGeneric( + new TypeReference( + new TypeIdentity("KeyValuePair", "System.Collections.Generic").MakeGeneric( + TypeIdentity.Create().AsTypeReference(), + TypeIdentity.Create().AsTypeReference().Nullable() + ) + ) + ) + .AsTypeReference(); + + await Assert.That(withNullableObject.Similar(withObject)).IsTrue(); + await Assert.That(withObject.Similar(withNullableObject)).IsTrue(); + await Assert.That(withNullableObject).IsNotEqualTo(withObject); + } + + [Test] + public async Task Similar_GivenSymbolSource_ComparesEqualToHandBuiltReference() + { + var symbol = TestCompilation.FieldType( + "public System.Collections.Generic.IEnumerable> Value = null!;" + ); + var handBuilt = new TypeIdentity(typeof(IEnumerable<>)) + .MakeGeneric( + new TypeReference( + new TypeIdentity("KeyValuePair", "System.Collections.Generic").MakeGeneric( + TypeIdentity.Create().AsTypeReference(), + TypeIdentity.Create().AsTypeReference().Nullable() + ) + ) + ) + .AsTypeReference(); + + await Assert.That(handBuilt.Similar(symbol)).IsTrue(); + await Assert.That(handBuilt.Matches(symbol)).IsTrue(); + await Assert.That(TypeReference.Create(symbol).Similar(handBuilt)).IsTrue(); + } + // --------------------------------------------------------------------------------------------- // Round-tripping from symbols // --------------------------------------------------------------------------------------------- diff --git a/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs index 9b35d00..6cc54b6 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/XmlCommentWriterTests.cs @@ -86,6 +86,154 @@ await Assert .IsEqualTo("/// \n/// first\n/// second\n/// \n"); } + // --------------------------------------------------------------------------------------------- + // cref-safe rendering + // --------------------------------------------------------------------------------------------- + + [Test] + public async Task ToXmlCref_KeywordType_RendersKeyword() + { + await Assert.That(XmlCommentWriter.ToXmlCref(new TypeIdentity(typeof(string)))).IsEqualTo("string"); + await Assert.That(XmlCommentWriter.ToXmlCref(new TypeIdentity(typeof(int)))).IsEqualTo("int"); + } + + [Test] + public async Task ToXmlCref_NamedType_RendersGlobalPrefixedName() + { + await Assert + .That(XmlCommentWriter.ToXmlCref(new TypeIdentity(typeof(ArgumentException)))) + .IsEqualTo("global::System.ArgumentException"); + } + + [Test] + public async Task ToXmlCref_ConstructedGeneric_UsesBraceForm() + { + var dictionary = new TypeIdentity(typeof(Dictionary<,>)).MakeGeneric( + TypeIdentity.Create(), + TypeIdentity.Create() + ); + + await Assert + .That(XmlCommentWriter.ToXmlCref(dictionary)) + .IsEqualTo("global::System.Collections.Generic.Dictionary{string,int}"); + } + + [Test] + public async Task ToXmlCref_OpenGeneric_UsesReadablePlaceholders() + { + await Assert + .That(XmlCommentWriter.ToXmlCref(new TypeIdentity(typeof(List<>)))) + .IsEqualTo("global::System.Collections.Generic.List{T0}"); + await Assert + .That(XmlCommentWriter.ToXmlCref(new TypeIdentity(typeof(Dictionary<,>)))) + .IsEqualTo("global::System.Collections.Generic.Dictionary{T0,T1}"); + } + + [Test] + public async Task ToXmlCref_NestedType_RendersDottedChain() + { + var nested = new TypeIdentity(typeof(Dictionary<,>)).Nested("Entry"); + + await Assert + .That(XmlCommentWriter.ToXmlCref(nested)) + .IsEqualTo("global::System.Collections.Generic.Dictionary{T0,T1}.Entry"); + } + + [Test] + public async Task ToXmlCref_TypeReference_ValueTypeNullable_UsesNullableWrapper() + { + await Assert + .That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeNullable())) + .IsEqualTo("global::System.Nullable{int}"); + } + + [Test] + public async Task ToXmlCref_TypeReference_ReferenceAnnotation_IsOmitted() + { + await Assert.That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeNullable())).IsEqualTo("string"); + } + + [Test] + public async Task ToXmlCref_TypeReference_ComposesModifiers() + { + await Assert.That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeArray())).IsEqualTo("int[]"); + await Assert.That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeArray(2))).IsEqualTo("int[,]"); + await Assert.That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakePointer())).IsEqualTo("int*"); + await Assert + .That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeNullable().MakeArray())) + .IsEqualTo("global::System.Nullable{int}[]"); + await Assert + .That(XmlCommentWriter.ToXmlCref(TypeIdentity.Create().MakeNullable().MakeArray().Nullable())) + .IsEqualTo("string[]"); + } + + [Test] + public async Task ToXmlCref_TypeReference_TypeParameter_UsesBraces() + { + await Assert.That(XmlCommentWriter.ToXmlCref(TypeReference.ForTypeParameter("T"))).IsEqualTo("{T}"); + } + + [Test] + public async Task ToXmlCref_TypeReference_GenericArgumentNullability_IsOmitted() + { + var list = new TypeIdentity(typeof(List<>)).MakeGeneric(TypeIdentity.Create().MakeNullable()); + + await Assert + .That(XmlCommentWriter.ToXmlCref(list.AsTypeReference())) + .IsEqualTo("global::System.Collections.Generic.List{string}"); + } + + [Test] + public async Task XmlCref_TypeReference_WritesCrefAttribute() + { + var writer = CodeWriterFactory.ForTests(); + + writer.XmlCref(TypeIdentity.Create().MakeNullable(), "value description"); + + await Assert + .That(writer.ToString()) + .IsEqualTo("/// value description\n"); + } + + [Test] + public async Task XmlException_TypeReference_WritesCrefAttribute() + { + var writer = CodeWriterFactory.ForTests(); + + writer.XmlException(TypeIdentity.Create().AsTypeReference(), "reason"); + + await Assert + .That(writer.ToString()) + .IsEqualTo("/// reason\n"); + } + + [Test] + public async Task XmlSee_TypeReference_ReturnsSelfClosingSee() + { + var see = XmlCommentWriter.XmlSee(TypeIdentity.Create().AsTypeReference()); + + await Assert.That(see).IsEqualTo(""); + } + + [Test] + public async Task XmlSeeAlso_TypeReference_WritesSelfClosingSeeAlso() + { + var writer = CodeWriterFactory.ForTests(); + + writer.XmlSeeAlso(TypeIdentity.Create().MakeNullable()); + + await Assert.That(writer.ToString()).IsEqualTo("/// \n"); + } + + [Test] + public async Task XmlCref_TypeReference_Null_Throws() + { + var writer = CodeWriterFactory.ForTests(); + TypeReference reference = null!; + + await Assert.That(() => writer.XmlCref(reference)).Throws(); + } + [Test] [Arguments(null)] [Arguments("")] From f7a01c2efbfc7a35cd0728ffbeb6ea6cdf7dee52 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 2 Sep 2026 22:57:29 +0100 Subject: [PATCH 2/2] refactor: updated tests to account for latest changes --- ...ckable-project-analyzer-defaults.prompt.md | 34 + global.json | 2 +- .../AnalyzerReleases.Unshipped.md | 12 +- .../AttributeDataModelValidationAnalyzer.cs | 595 ++++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 13 +- .../AttributeDataModelGenerator.cs | 5 +- .../Helpers/AttributeDataModelLibrary.cs | 161 ++--- .../Helpers/DiagnosticLibrary.cs | 87 --- .../Model/AttributeDataModelTarget.cs | 8 +- .../SourceGeneratorFramework.csproj | 10 +- .../SourceGeneratorShared/DiagnosticInfo.cs | 36 +- ...tributeDataModelValidationAnalyzerTests.cs | 396 ++++++++++++ .../AttributeDataModelGeneratorCacheTests.cs | 129 ++++ .../AttributeDataModelGeneratorTests.cs | 39 +- .../DiagnosticInfoTests.cs | 42 ++ .../TestGenerators/Models.cs | 7 - .../TestGenerators/TestGenerator.cs | 26 +- 17 files changed, 1337 insertions(+), 265 deletions(-) create mode 100644 .agents/prompts/sdk-packable-project-analyzer-defaults.prompt.md create mode 100644 src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelValidationAnalyzer.cs delete mode 100644 src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelValidationAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorCacheTests.cs diff --git a/.agents/prompts/sdk-packable-project-analyzer-defaults.prompt.md b/.agents/prompts/sdk-packable-project-analyzer-defaults.prompt.md new file mode 100644 index 0000000..99b446a --- /dev/null +++ b/.agents/prompts/sdk-packable-project-analyzer-defaults.prompt.md @@ -0,0 +1,34 @@ +--- +agent: ask +description: "Ensure the Purview.DotNetProjectSdk applies analyzer/source-generator best-practice defaults to packable projects, so consumers of Purview.SourceGeneratorFramework (and any Roslyn component) get correct packaging without per-project overrides." +--- + +You are working on the **Purview.DotNetProjectSdk** project (the `Purview.DotNetProjectSdk` package that projects import via ``). Apply this checklist to its `Sdk.props` / `Sdk.targets` so that **packable** analyzer and source-generator projects get Roslyn best-practice defaults automatically. + +## Context + +The `Purview.SourceGeneratorFramework` guidance requires every analyzer/generator project to set these properties or the shipped analyzer asset is broken or unoptimised. Consumers should not have to set them per project. The SDK is the right place to default them. + +## Task + +For projects the SDK classifies as analyzers/source generators (`IsRoslynComponent`, or projects that produce `analyzers/dotnet/cs` assets) and that are packable, ensure the following are the **defaults** (still overridable by the project): + +1. `TargetFramework=netstandard2.0` unless the project explicitly overrides it. +2. `LangVersion=latest` and `Nullable=enable`. +3. `IncludeBuildOutput=false` (so the library isn't packed as a `lib/` asset) and the analyzer/generator DLL packed into `analyzers/dotnet/cs`: + ```xml + + ``` +4. `EnforceExtendedAnalyzerRules=true` and `TreatWarningsAsErrors=true`. +5. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) referenced with `PrivateAssets="all"`. + +## Requirements + +- Do not break existing projects that already set these explicitly; the SDK defaults must be overridable. +- Handle the distinction between packable and non-packable projects — only packable analyzer/generator projects get the analyzer-asset packaging. +- Ensure the defaults apply at the right evaluation point in the SDK (props vs targets) so project files can still override with normal `PropertyGroup` values. +- Verify with a consumer project that references a generator through the SDK that the packed `.nupkg` contains `analyzers/dotnet/cs/.dll` and the `lib/` output is empty. + +## Background + +See the `source-generator-codewriter-modernization` skill's "Recommended project configuration" for the canonical generator project shape this SDK should default to. \ No newline at end of file diff --git a/global.json b/global.json index 6cef581..e60c344 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.DotNetProjectSdk": "1.0.0-prerelease.44" + "Purview.DotNetProjectSdk": "1.0.0-prerelease.45" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index ba3020d..9fab643 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,4 +1,5 @@ ### New Rules + Rule ID | Category | Severity | Notes --------|----------|----------|------- PSGF001 | Purview.SourceGeneratorFramework | Error | Generation capabilities must be a record @@ -7,4 +8,13 @@ PSGFR12 | Purview.SourceGeneratorFramework | Warning | Use IIncrementalGenerator PSGFR14 | Purview.SourceGeneratorFramework | Warning | Avoid RegisterImplementationSourceOutput PSGFR15 | Purview.SourceGeneratorFramework | Warning | Pipeline model collection lacks sequence equality PSGFR16 | Purview.SourceGeneratorFramework | Info | Prefer the nullable-context overload -ADM0010 | Property | Error | Attribute data model property type is not cacheable | +ADM0001 | Target | Error | Target attribute type cannot be resolved +ADM0002 | Property | Error | Property type is not supported for attribute extraction +ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute +ADM0004 | NestedModel | Error | Nested model type is not annotated with GenerateAttributeDataModel +ADM0005 | DefaultValue | Error | Default value cannot be emitted for the property type +ADM0006 | DefaultValue | Error | Non-nullable reference type property requires a default value +ADM0007 | AutoDiscovery | Error | Auto-discovery requires a target attribute type +ADM0008 | TypeArgument | Error | Type argument property type must be TypeIdentity +ADM0009 | Property | Error | IsEnum property must be a string type +ADM0010 | Property | Error | Attribute data model property type is not cacheable | \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelValidationAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelValidationAnalyzer.cs new file mode 100644 index 0000000..90d1aa0 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/AttributeDataModelValidationAnalyzer.cs @@ -0,0 +1,595 @@ +using System.Collections.Immutable; +using System.Globalization; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Reports attribute-data model validation diagnostics (ADM0001-ADM0009) on the record structs annotated +/// with Purview.SourceGeneratorFramework.Generators.GenerateAttribute. These rules were moved out of the +/// source generator so they run as standard IDE/build analyzers instead of generator-reported diagnostics. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AttributeDataModelValidationAnalyzer : DiagnosticAnalyzer +{ + public static readonly DiagnosticDescriptor TargetAttributeNotResolved = new( + "ADM0001", + "Target attribute type cannot be resolved", + "Target attribute type for '{0}' cannot be resolved", + "Target", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor PropertyTypeNotSupported = new( + "ADM0002", + "Property type is not supported for attribute extraction", + "Property '{0}' type '{1}' is not supported for attribute extraction", + "Property", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor ConstructorMemberNotFound = new( + "ADM0003", + "Specified constructor index/name does not exist on the target attribute", + "Constructor argument '{0}' does not exist on target attribute '{1}'", + "Source", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor NestedModelNotGenerated = new( + "ADM0004", + "Nested model type is not annotated with GenerateAttributeDataModel", + "Nested model type '{0}' is not annotated with GenerateAttributeDataModel", + "NestedModel", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor DefaultValueNotSupported = new( + "ADM0005", + "Default value cannot be emitted for the property type", + "Default value '{0}' cannot be emitted for property type '{1}'", + "DefaultValue", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor NonNullableReferenceTypeRequiresDefault = new( + "ADM0006", + "Non-nullable reference type property requires a default value", + "Non-nullable reference type property '{0}' requires an explicit or inferred default value", + "DefaultValue", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor AutoDiscoverRequiresType = new( + "ADM0007", + "Auto-discovery requires a target attribute type", + "Auto-discovery requires a target attribute type; use the Type constructor overload instead of the string overload", + "AutoDiscovery", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor TypeArgumentPropertyTypeInvalid = new( + "ADM0008", + "Type argument property type must be TypeIdentity", + "Type argument property '{0}' type '{1}' must be Purview.SourceGeneratorFramework.TypeIdentity", + "TypeArgument", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor IsEnumRequiresStringType = new( + "ADM0009", + "IsEnum property must be a string type", + "Property '{0}' is marked with IsEnum but its type '{1}' is not a string; IsEnum requires a string or string? property type", + "Property", + DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => + [ + TargetAttributeNotResolved, + PropertyTypeNotSupported, + ConstructorMemberNotFound, + NestedModelNotGenerated, + DefaultValueNotSupported, + NonNullableReferenceTypeRequiresDefault, + AutoDiscoverRequiresType, + TypeArgumentPropertyTypeInvalid, + IsEnumRequiresStringType, + ]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(context => + { + var generateAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.GenerateAttribute" + ); + var propertyAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.PropertyAttribute" + ); + var argumentAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.ArgumentAttribute" + ); + var nestedModelAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.NestedModelAttribute" + ); + var excludeAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.ExcludeAttribute" + ); + var typeArgumentAttributeType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.Generators.GenericTypeArgumentAttribute" + ); + var typeIdentityType = context.Compilation.GetTypeByMetadataName( + "Purview.SourceGeneratorFramework.TypeIdentity" + ); + var typedConstantType = context.Compilation.GetTypeByMetadataName("Microsoft.CodeAnalysis.TypedConstant"); + + context.RegisterSymbolAction( + context => + AnalyzeNamedType( + context, + generateAttributeType, + propertyAttributeType, + argumentAttributeType, + nestedModelAttributeType, + excludeAttributeType, + typeArgumentAttributeType, + typeIdentityType, + typedConstantType + ), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? generateAttributeType, + INamedTypeSymbol? propertyAttributeType, + INamedTypeSymbol? argumentAttributeType, + INamedTypeSymbol? nestedModelAttributeType, + INamedTypeSymbol? excludeAttributeType, + INamedTypeSymbol? typeArgumentAttributeType, + INamedTypeSymbol? typeIdentityType, + INamedTypeSymbol? typedConstantType + ) + { + if (context.Symbol is not INamedTypeSymbol typeSymbol) + return; + + if (typeSymbol.TypeKind is not TypeKind.Struct and not TypeKind.Class) + return; + + if (generateAttributeType is null) + return; + + var generateAttribute = GetAttribute(typeSymbol, generateAttributeType); + if (generateAttribute is null) + return; + + var typeLocation = typeSymbol.Locations.FirstOrDefault(static location => location.IsInSource) ?? Location.None; + + var (targetAttributeType, _) = AnalyzeTargetAttribute(context, generateAttribute, typeLocation, typeSymbol); + + var targetConstructorParameters = targetAttributeType is INamedTypeSymbol namedTargetType + ? namedTargetType.InstanceConstructors.SelectMany(static ctor => ctor.Parameters).ToImmutableArray() + : []; + + foreach (var constructor in typeSymbol.InstanceConstructors) + { + foreach (var parameter in constructor.Parameters) + { + if (!parameter.Locations.Any(static location => location.IsInSource)) + continue; + + AnalyzeParameter( + context, + parameter, + generateAttributeType, + targetAttributeType, + targetConstructorParameters, + propertyAttributeType, + argumentAttributeType, + nestedModelAttributeType, + excludeAttributeType, + typeArgumentAttributeType, + typeIdentityType, + typedConstantType + ); + } + } + } + + static (ITypeSymbol? TargetAttributeType, bool HasTargetType) AnalyzeTargetAttribute( + SymbolAnalysisContext context, + AttributeData generateAttribute, + Location typeLocation, + INamedTypeSymbol typeSymbol + ) + { + if (generateAttribute.ConstructorArguments.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(TargetAttributeNotResolved, typeLocation, typeSymbol.Name)); + return (null, false); + } + + var firstArgument = generateAttribute.ConstructorArguments[0].Value; + if (firstArgument is ITypeSymbol typeArgument) + return (typeArgument, true); + + if (firstArgument is not string) + context.ReportDiagnostic(Diagnostic.Create(TargetAttributeNotResolved, typeLocation, typeSymbol.Name)); + + if (GetBoolNamedArgument(generateAttribute, "AutoDiscover")) + context.ReportDiagnostic(Diagnostic.Create(AutoDiscoverRequiresType, typeLocation, typeSymbol.Name)); + + return (null, false); + } + + static void AnalyzeParameter( + SymbolAnalysisContext context, + IParameterSymbol parameter, + INamedTypeSymbol generateAttributeType, + ITypeSymbol? targetAttributeType, + ImmutableArray targetConstructorParameters, + INamedTypeSymbol? propertyAttributeType, + INamedTypeSymbol? argumentAttributeType, + INamedTypeSymbol? nestedModelAttributeType, + INamedTypeSymbol? excludeAttributeType, + INamedTypeSymbol? typeArgumentAttributeType, + INamedTypeSymbol? typeIdentityType, + INamedTypeSymbol? typedConstantType + ) + { + var parameterLocation = parameter.Locations.First(static location => location.IsInSource); + + // ADM0002 - unsupported property type (reported before exclusion handling, matching the generator). + if (parameter.Type.TypeKind is TypeKind.Array or TypeKind.Pointer or TypeKind.FunctionPointer) + { + context.ReportDiagnostic( + Diagnostic.Create( + PropertyTypeNotSupported, + parameterLocation, + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + return; + } + + var nestedModelAttribute = GetAttribute(parameter, nestedModelAttributeType); + var typeArgumentAttribute = GetAttribute(parameter, typeArgumentAttributeType); + var excludeAttribute = GetAttribute(parameter, excludeAttributeType); + var argumentAttribute = GetAttribute(parameter, argumentAttributeType); + var propertyAttribute = GetAttribute(parameter, propertyAttributeType); + + var isExcluded = excludeAttribute is not null; + var isNestedModel = nestedModelAttribute is not null; + var isTypeArgument = typeArgumentAttribute is not null; + var hasExclusive = isExcluded || isNestedModel || isTypeArgument; + + // ADM0003 - the specified constructor member does not exist on the target attribute. + if (argumentAttribute is not null && targetAttributeType is not null && !hasExclusive) + { + ValidateConstructorMember( + context, + argumentAttribute, + parameterLocation, + targetConstructorParameters, + targetAttributeType + ); + } + + if (isExcluded) + return; + + if (isNestedModel && !IsGeneratedAttributeModel(parameter.Type, generateAttributeType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + NestedModelNotGenerated, + parameterLocation, + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + if (isTypeArgument && !IsTypeIdentityType(parameter.Type, typeIdentityType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + TypeArgumentPropertyTypeInvalid, + parameterLocation, + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + if (HasIsEnum(propertyAttribute, argumentAttribute) && parameter.Type.SpecialType != SpecialType.System_String) + { + context.ReportDiagnostic( + Diagnostic.Create( + IsEnumRequiresStringType, + parameterLocation, + parameter.Name, + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + var defaultValue = GetEffectiveDefaultValue( + nestedModelAttribute, + typeArgumentAttribute, + argumentAttribute, + propertyAttribute, + hasExclusive + ); + + if (defaultValue is not null && !IsDefaultValueEmittable(defaultValue, parameter.Type, typedConstantType)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DefaultValueNotSupported, + parameterLocation, + Convert.ToString(defaultValue, CultureInfo.InvariantCulture) ?? "null", + parameter.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + // ADM0006 - a non-nullable reference type property must supply a default value. + if ( + parameter.Type.IsReferenceType + && parameter.Type.NullableAnnotation == NullableAnnotation.NotAnnotated + && defaultValue is null + ) + { + context.ReportDiagnostic( + Diagnostic.Create(NonNullableReferenceTypeRequiresDefault, parameterLocation, parameter.Name) + ); + } + } + + static void ValidateConstructorMember( + SymbolAnalysisContext context, + AttributeData argumentAttribute, + Location parameterLocation, + ImmutableArray targetConstructorParameters, + ITypeSymbol targetAttributeType + ) + { + var name = GetCtorName(argumentAttribute); + var index = GetCtorIndex(argumentAttribute); + + if (name is not null) + { + if (!targetConstructorParameters.Any(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConstructorMemberNotFound, + parameterLocation, + name, + targetAttributeType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + + return; + } + + if (index >= 0 && !targetConstructorParameters.Any(p => p.Ordinal == index)) + { + context.ReportDiagnostic( + Diagnostic.Create( + ConstructorMemberNotFound, + parameterLocation, + index.ToString(CultureInfo.InvariantCulture), + targetAttributeType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ) + ); + } + } + + static string? GetCtorName(AttributeData attributeData) + { + if (attributeData.ConstructorArguments.Length > 0 && attributeData.ConstructorArguments[0].Value is string name) + return name; + + // If the name is not specified in the constructor arguments, check for a named argument "Name". + return GetStringNamedArgument(attributeData, "Name"); + } + + static int GetCtorIndex(AttributeData attributeData) + { + if (attributeData.ConstructorArguments.Length > 0 && attributeData.ConstructorArguments[0].Value is int index) + return index; + + // If the index is not specified in the constructor arguments, check for a named argument "Index". + return GetIntNamedArgument(attributeData, "Index", -1); + } + + static object? GetEffectiveDefaultValue( + AttributeData? nestedModelAttribute, + AttributeData? typeArgumentAttribute, + AttributeData? argumentAttribute, + AttributeData? propertyAttribute, + bool hasExclusive + ) + { + object? defaultValue = null; + if (nestedModelAttribute is not null) + defaultValue = GetObjectNamedArgument(nestedModelAttribute, "DefaultValue"); + else if (typeArgumentAttribute is not null) + defaultValue = GetObjectNamedArgument(typeArgumentAttribute, "DefaultValue"); + + if (argumentAttribute is not null && !hasExclusive) + { + var argumentDefault = GetObjectNamedArgument( + argumentAttribute, + "DefaultValue", + argumentAttribute.ConstructorArguments.Length > 1 + ? argumentAttribute.ConstructorArguments[1].Value + : null + ); + if (argumentDefault is not null) + defaultValue = argumentDefault; + } + + if (propertyAttribute is not null && !hasExclusive) + { + var propertyDefault = GetObjectNamedArgument( + propertyAttribute, + "DefaultValue", + propertyAttribute.ConstructorArguments.Length > 0 + ? propertyAttribute.ConstructorArguments[0].Value + : null + ); + if (propertyDefault is not null) + defaultValue = propertyDefault; + } + + return defaultValue; + } + + static bool HasIsEnum(AttributeData? propertyAttribute, AttributeData? argumentAttribute) => + (propertyAttribute is not null && GetBoolNamedArgument(propertyAttribute, "IsEnum")) + || (argumentAttribute is not null && GetBoolNamedArgument(argumentAttribute, "IsEnum")); + + static bool IsDefaultValueEmittable(object? value, ITypeSymbol parameterType, INamedTypeSymbol? typedConstantType) + { + if (value is null) + return true; + + if (value is string) + return typedConstantType is null + || !SymbolEqualityComparer.Default.Equals(parameterType.OriginalDefinition, typedConstantType); + + if (value is bool) + return true; + + if (value is ITypeSymbol) + return true; + + if (parameterType.TypeKind == TypeKind.Enum) + return true; + + // Check if the parameter type implements IFormattable for numeric types. + return value is IFormattable; + } + + static bool IsTypeIdentityType(ITypeSymbol typeSymbol, INamedTypeSymbol? typeIdentityType) + { + if (typeIdentityType is null) + return false; + + var candidate = typeSymbol; + if ( + candidate is INamedTypeSymbol nullableType + && nullableType.IsValueType + && nullableType.ContainingNamespace?.ToDisplayString() == "System" + && nullableType.Name == "Nullable" + && nullableType.TypeArguments.Length == 1 + ) + { + candidate = nullableType.TypeArguments[0]; + } + + if (candidate is not INamedTypeSymbol namedType) + return false; + + var namespaceName = namedType.ContainingNamespace.IsGlobalNamespace + ? null + : namedType.ContainingNamespace.ToDisplayString(); + + return namespaceName == typeIdentityType.ContainingNamespace.ToDisplayString() + && namedType.Name == typeIdentityType.Name; + } + + static bool IsGeneratedAttributeModel(ITypeSymbol typeSymbol, INamedTypeSymbol generateAttributeType) + { + if (typeSymbol is not INamedTypeSymbol namedType || namedType.TypeKind != TypeKind.Struct) + return false; + + // Check if the type has the GenerateAttribute applied. + return GetAttribute(namedType, generateAttributeType) is not null; + } + + static AttributeData? GetAttribute(ISymbol symbol, INamedTypeSymbol? attributeType) + { + if (attributeType is null) + return null; + + foreach (var attribute in symbol.GetAttributes()) + { + if ( + attribute.AttributeClass is not null + && SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeType) + ) + return attribute; + } + + return null; + } + + static string? GetStringNamedArgument(AttributeData attributeData, string name) + { + foreach (var argument in attributeData.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is string value) + return value; + } + + return null; + } + + static int GetIntNamedArgument(AttributeData attributeData, string name, int defaultValue) + { + foreach (var argument in attributeData.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is int value) + return value; + } + + return defaultValue; + } + + static bool GetBoolNamedArgument(AttributeData attributeData, string name) + { + foreach (var argument in attributeData.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is bool value) + return value; + } + + return false; + } + + static object? GetObjectNamedArgument(AttributeData attributeData, string name, object? defaultValue = null) + { + foreach (var argument in attributeData.NamedArguments) + { + if (argument.Key == name) + return argument.Value.Value; + } + + return defaultValue; + } +} diff --git a/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md index 51b3992..bc2faee 100644 --- a/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Generators/AnalyzerReleases.Unshipped.md @@ -1,12 +1 @@ -### New Rules -| Rule ID | Category | Severity | Notes | -|---------|----------|----------|-------| -| ADM0001 | Target | Error | Target attribute type cannot be resolved | -| ADM0002 | Property | Error | Property type is not supported for attribute extraction | -| ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute | -| ADM0004 | NestedModel | Error | Nested model type is not annotated with GenerateAttributeDataModel | -| ADM0005 | DefaultValue | Error | Default value cannot be emitted for the property type | -| ADM0006 | DefaultValue | Error | Non-nullable reference type property requires a default value | -| ADM0007 | AutoDiscovery | Error | Auto-discovery requires a target attribute type | -| ADM0008 | TypeArgument | Error | Type argument property type must be TypeIdentity | -| ADM0009 | Property | Error | IsEnum property must be a string type | +### New Rules \ No newline at end of file diff --git a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs index 74a70f0..cdf9d23 100644 --- a/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs +++ b/src/src/SourceGeneratorFramework.Generators/AttributeDataModelGenerator.cs @@ -40,9 +40,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return; } - if (target.HasDiagnostics) - spc.ReportDiagnostics(target.Diagnostics); - + // Validation diagnostics (ADM0001-ADM0010) are reported by the analyzers; the generator only + // skips processing targets that carry a blocking error. if (!target.ShouldProcess) return; diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs index 66febce..c7bab7a 100644 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs +++ b/src/src/SourceGeneratorFramework.Generators/Helpers/AttributeDataModelLibrary.cs @@ -33,7 +33,10 @@ static GeneratorResult BuildTarget( CancellationToken cancellationToken ) { - var diagnostics = ImmutableArray.CreateBuilder(); + // Validation diagnostics (ADM0001-ADM0010) are reported by AttributeDataModelValidationAnalyzer and + // AttributeDataModelSymbolPropertyAnalyzer; the generator only tracks whether a blocking error exists so + // it can gate generation without emitting the diagnostics itself. + var hasBlockingError = false; var generateAttribute = GetAttribute(structSymbol, GeneratorTypeLibrary.Attirbutes.GenerateAttribute); if (generateAttribute is null) return GeneratorResult.Empty; @@ -42,9 +45,7 @@ CancellationToken cancellationToken TypeIdentity targetAttribute = default; if (generateAttribute.ConstructorArguments.Length == 0) { - diagnostics.Add( - DiagnosticInfo.Create(DiagnosticLibrary.TargetAttributeNotResolved, structSymbol, structSymbol.Name) - ); + hasBlockingError = true; } else { @@ -58,11 +59,7 @@ CancellationToken cancellationToken else if (firstArgument is string targetAttributeName) targetAttribute = ParseTypeValueObject(targetAttributeName); else - { - diagnostics.Add( - DiagnosticInfo.Create(DiagnosticLibrary.TargetAttributeNotResolved, structSymbol, structSymbol.Name) - ); - } + hasBlockingError = true; } var matchByInheritance = GetNamedArgument( @@ -77,23 +74,31 @@ CancellationToken cancellationToken ); if (autoDiscover && targetAttributeType is null) - diagnostics.Add(DiagnosticInfo.Create(DiagnosticLibrary.AutoDiscoverRequiresType, structSymbol)); + hasBlockingError = true; var excludedNames = new HashSet(StringComparer.Ordinal); - var explicitProperties = ReadExplicitProperties(structSymbol, excludedNames, diagnostics, cancellationToken); + var explicitProperties = ReadExplicitProperties( + structSymbol, + excludedNames, + ref hasBlockingError, + cancellationToken + ); var discoveredProperties = autoDiscover && targetAttributeType is not null ? DiscoverProperties( targetAttributeType, explicitProperties, excludedNames, - diagnostics, + ref hasBlockingError, cancellationToken ) : []; var mergedProperties = MergeProperties(explicitProperties, discoveredProperties); + if (hasBlockingError) + return GeneratorResult.Empty; + var target = new AttributeDataModelTarget( Namespace: structSymbol.ContainingNamespace.IsGlobalNamespace ? null @@ -110,13 +115,10 @@ CancellationToken cancellationToken explicitProperties, cancellationToken ), - Properties: new EquatableArray(mergedProperties), - Diagnostics: new EquatableArray(diagnostics.ToImmutable()) + Properties: new EquatableArray(mergedProperties) ); - return diagnostics.Count > 0 - ? GeneratorResult.Create([.. diagnostics]) - : GeneratorResult.Create(target); + return GeneratorResult.Create(target); } static EquatableArray GetPrimaryConstructorArguments( @@ -147,7 +149,7 @@ CancellationToken cancellationToken static ImmutableArray ReadExplicitProperties( INamedTypeSymbol structSymbol, HashSet excludedNames, - ImmutableArray.Builder diagnostics, + ref bool hasBlockingError, CancellationToken cancellationToken ) { @@ -164,27 +166,13 @@ CancellationToken cancellationToken var propertyType = parameter.Type; if (!IsSupportedType(propertyType)) { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.PropertyTypeNotSupported, - parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(propertyType) - ) - ); + hasBlockingError = true; continue; } if (IsSymbolOrSystemType(propertyType)) { - diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, - parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(propertyType) - ) - ); + hasBlockingError = true; continue; } @@ -197,39 +185,13 @@ CancellationToken cancellationToken } if (info.IsNestedModel && !IsGeneratedAttributeModel(propertyType)) - { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.NestedModelNotGenerated, - parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), - TypeHelpers.ToFullyQualifiedDisplayString(propertyType) - ) - ); - } + hasBlockingError = true; if (info.IsTypeArgument && !IsTypeIdentityType(propertyType)) - { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.TypeArgumentPropertyTypeInvalid, - parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(propertyType) - ) - ); - } + hasBlockingError = true; if (info.IsEnum && propertyType.SpecialType != SpecialType.System_String) - { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.IsEnumRequiresStringType, - parameter.Locations.FirstOrDefault(static loc => loc.IsInSource), - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(propertyType) - ) - ); - } + hasBlockingError = true; var sources = info.Sources; if (sources.IsEmpty) @@ -240,7 +202,7 @@ CancellationToken cancellationToken info.DefaultValue, modelTypeName, propertyType, - diagnostics + ref hasBlockingError ); properties.Add( @@ -441,7 +403,7 @@ static ImmutableArray DiscoverProperties( ITypeSymbol targetAttributeType, ImmutableArray explicitProperties, HashSet excludedNames, - ImmutableArray.Builder diagnostics, + ref bool hasBlockingError, CancellationToken cancellationToken ) { @@ -471,34 +433,24 @@ CancellationToken cancellationToken if (!IsSupportedType(parameter.Type)) { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.PropertyTypeNotSupported, - Location.None, - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(parameter.Type) - ) - ); + hasBlockingError = true; continue; } if (IsSymbolOrSystemType(parameter.Type)) { - diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, - Location.None, - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(parameter.Type) - ) - ); + hasBlockingError = true; continue; } discoveredNames.Add(propertyName); var (modelTypeName, isNonNullableReferenceType) = GetModelTypeInfo(parameter.Type, autoDiscover: true); - var defaultValueExpression = GetInferredDefaultExpression(parameter, modelTypeName, diagnostics); + var defaultValueExpression = GetInferredDefaultExpression( + parameter, + modelTypeName, + ref hasBlockingError + ); discovered.Add( new AttributeDataModelProperty( @@ -541,34 +493,25 @@ CancellationToken cancellationToken if (!IsSupportedType(property.Type)) { - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.PropertyTypeNotSupported, - Location.None, - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(property.Type) - ) - ); + hasBlockingError = true; continue; } if (IsSymbolOrSystemType(property.Type)) { - diagnostics.Add( - DiagnosticInfo.Create( - AttributeDataModelDiagnosticRules.SymbolPropertyNotCacheable, - Location.None, - propertyName, - TypeHelpers.ToFullyQualifiedDisplayString(property.Type) - ) - ); + hasBlockingError = true; continue; } discoveredNames.Add(propertyName); var (modelTypeName, isNonNullableReferenceType) = GetModelTypeInfo(property.Type, autoDiscover: true); - var defaultValueExpression = GetDefaultValueExpression(null, modelTypeName, property.Type, diagnostics); + var defaultValueExpression = GetDefaultValueExpression( + null, + modelTypeName, + property.Type, + ref hasBlockingError + ); discovered.Add( new( @@ -622,7 +565,7 @@ static string GetDefaultValueExpression( object? defaultValue, string modelTypeName, ITypeSymbol originalType, - ImmutableArray.Builder diagnostics + ref bool hasBlockingError ) { if (defaultValue is not null) @@ -630,14 +573,7 @@ ImmutableArray.Builder diagnostics if (TryFormatValue(defaultValue, originalType, out var expression)) return expression; - diagnostics.Add( - DiagnosticInfo.Create( - DiagnosticLibrary.DefaultValueNotSupported, - Location.None, - defaultValue.ToString() ?? "null", - TypeHelpers.ToFullyQualifiedDisplayString(originalType) - ) - ); + hasBlockingError = true; } return $"default({modelTypeName})"; @@ -646,11 +582,16 @@ ImmutableArray.Builder diagnostics static string GetInferredDefaultExpression( IParameterSymbol parameter, string modelTypeName, - ImmutableArray.Builder diagnostics + ref bool hasBlockingError ) { return parameter.HasExplicitDefaultValue - ? GetDefaultValueExpression(parameter.ExplicitDefaultValue, modelTypeName, parameter.Type, diagnostics) + ? GetDefaultValueExpression( + parameter.ExplicitDefaultValue, + modelTypeName, + parameter.Type, + ref hasBlockingError + ) : $"default({modelTypeName})"; } diff --git a/src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs b/src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs deleted file mode 100644 index acefcc0..0000000 --- a/src/src/SourceGeneratorFramework.Generators/Helpers/DiagnosticLibrary.cs +++ /dev/null @@ -1,87 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace Purview.SourceGeneratorFramework.Generators.Helpers; - -static class DiagnosticLibrary -{ - public static readonly DiagnosticDescriptor TargetAttributeNotResolved = new( - "ADM0001", - "Target attribute type cannot be resolved", - "Target attribute type for '{0}' cannot be resolved", - "Target", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor PropertyTypeNotSupported = new( - "ADM0002", - "Property type is not supported for attribute extraction", - "Property '{0}' type '{1}' is not supported for attribute extraction", - "Property", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor ConstructorMemberNotFound = new( - "ADM0003", - "Specified constructor index/name does not exist on the target attribute", - "Constructor argument '{0}' does not exist on target attribute '{1}'", - "Source", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor NestedModelNotGenerated = new( - "ADM0004", - "Nested model type is not annotated with GenerateAttributeDataModel", - "Nested model type '{0}' is not annotated with GenerateAttributeDataModel", - "NestedModel", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor DefaultValueNotSupported = new( - "ADM0005", - "Default value cannot be emitted for the property type", - "Default value '{0}' cannot be emitted for property type '{1}'", - "DefaultValue", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor NonNullableReferenceTypeRequiresDefault = new( - "ADM0006", - "Non-nullable reference type property requires a default value", - "Non-nullable reference type property '{0}' requires an explicit or inferred default value", - "DefaultValue", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor AutoDiscoverRequiresType = new( - "ADM0007", - "Auto-discovery requires a target attribute type", - "Auto-discovery requires a target attribute type; use the Type constructor overload instead of the string overload", - "AutoDiscovery", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor TypeArgumentPropertyTypeInvalid = new( - "ADM0008", - "Type argument property type must be TypeIdentity", - "Type argument property '{0}' type '{1}' must be Purview.SourceGeneratorFramework.TypeIdentity", - "TypeArgument", - DiagnosticSeverity.Error, - true - ); - - public static readonly DiagnosticDescriptor IsEnumRequiresStringType = new( - "ADM0009", - "IsEnum property must be a string type", - "Property '{0}' is marked with IsEnum but its type '{1}' is not a string; IsEnum requires a string or string? property type", - "Property", - DiagnosticSeverity.Error, - true - ); -} diff --git a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs index 9c5aca4..f0e2363 100644 --- a/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs +++ b/src/src/SourceGeneratorFramework.Generators/Model/AttributeDataModelTarget.cs @@ -10,9 +10,5 @@ sealed record AttributeDataModelTarget( bool MatchByInheritance, bool AutoDiscover, EquatableArray PrimaryConstructorArguments, - EquatableArray Properties, - EquatableArray Diagnostics -) -{ - public bool HasDiagnostics => !Diagnostics.IsEmpty; -} + EquatableArray Properties +); diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index 57ff427..0f6c25b 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -1,14 +1,6 @@  - - netstandard2.0 - true - - true - $(NoWarn);CS1591; - $(RootNamespace) - true + true $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorShared;IncludeAnalyzerAssembly diff --git a/src/src/SourceGeneratorShared/DiagnosticInfo.cs b/src/src/SourceGeneratorShared/DiagnosticInfo.cs index 6947689..dd77208 100644 --- a/src/src/SourceGeneratorShared/DiagnosticInfo.cs +++ b/src/src/SourceGeneratorShared/DiagnosticInfo.cs @@ -12,10 +12,44 @@ public sealed record DiagnosticInfo( string FilePath, TextSpan TextSpan, LinePositionSpan LinePositionSpan, - ImmutableArray AdditionalLinePositions, + EquatableArray AdditionalLinePositions, ImmutableArray MessageArgs ) { + /// + /// + /// is compared by content (object.Equals per argument, which is + /// value-based for the primitive/string/enum values used as message arguments) so that two diagnostics + /// created in separate generator runs are equal and do not invalidate the incremental cache. + /// + public bool Equals(DiagnosticInfo? other) => + other is not null + && Descriptor.Equals(other.Descriptor) + && string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) + && TextSpan == other.TextSpan + && LinePositionSpan == other.LinePositionSpan + && AdditionalLinePositions.Equals(other.AdditionalLinePositions) + && MessageArgs.SequenceEqual(other.MessageArgs); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = (hash * 31) + Descriptor.GetHashCode(); + hash = (hash * 31) + (FilePath?.GetHashCode() ?? 0); + hash = (hash * 31) + TextSpan.GetHashCode(); + hash = (hash * 31) + LinePositionSpan.GetHashCode(); + foreach (var span in AdditionalLinePositions.AsImmutableArray()) + hash = (hash * 31) + span.GetHashCode(); + foreach (var argument in MessageArgs) + hash = (hash * 31) + (argument?.GetHashCode() ?? 0); + + return hash; + } + } + /// /// Converts this back into a Roslyn . /// diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelValidationAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelValidationAnalyzerTests.cs new file mode 100644 index 0000000..6b1ba0c --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AttributeDataModelValidationAnalyzerTests.cs @@ -0,0 +1,396 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class AttributeDataModelValidationAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + const string AttributeDefinition = """ + using System; + using Microsoft.CodeAnalysis; + using Purview.SourceGeneratorFramework; + using Purview.SourceGeneratorFramework.Generators; + + namespace Purview.SourceGeneratorFramework.Generators + { + [AttributeUsage(AttributeTargets.Struct, Inherited = false, AllowMultiple = false)] + public sealed class GenerateAttribute : Attribute + { + public GenerateAttribute(Type targetAttribute) { } + public GenerateAttribute(string targetAttributeName) { } + public bool MatchByInheritance { get; set; } + public bool AutoDiscover { get; set; } + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class PropertyAttribute : Attribute + { + public PropertyAttribute(object? defaultValue = null) { } + public string? Name { get; set; } + public object? DefaultValue { get; set; } + public bool IsEnum { get; set; } + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class ArgumentAttribute : Attribute + { + public ArgumentAttribute(string? name = null, object? defaultValue = null) { } + public ArgumentAttribute(int index, object? defaultValue = null) { } + public string? Name { get; set; } + public int Index { get; set; } = -1; + public object? DefaultValue { get; set; } + public bool IsEnum { get; set; } + } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class NestedModelAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class ExcludeAttribute : Attribute { } + + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class GenericTypeArgumentAttribute : Attribute + { + public GenericTypeArgumentAttribute() { } + public GenericTypeArgumentAttribute(int index) { } + public GenericTypeArgumentAttribute(string name) { } + public string? Name { get; set; } + public int Index { get; set; } = -1; + } + + public sealed class TestAttribute : Attribute + { + public TestAttribute(string mode) { } + public string? Mode { get; set; } + } + } + + namespace Purview.SourceGeneratorFramework + { + public readonly record struct TypeIdentity; + } + """; + + [Test] + public async Task Generate_WithNoTarget_ReportsTargetAttributeNotResolved(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate] + public readonly record struct MyModel(bool Enabled); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.TargetAttributeNotResolved.Id); + } + + [Test] + public async Task ArrayProperty_ReportsPropertyTypeNotSupported(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + string? Mode, + int[] Values + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.PropertyTypeNotSupported.Id); + } + + [Test] + public async Task ArgumentWithMissingName_ReportsConstructorMemberNotFound(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [Argument("missing")] string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.ConstructorMemberNotFound.Id); + } + + [Test] + public async Task ArgumentWithOutOfRangeIndex_ReportsConstructorMemberNotFound(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [Argument(5)] string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.ConstructorMemberNotFound.Id); + } + + [Test] + public async Task ArgumentWithValidName_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [Argument("mode")] string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task NestedModelWithoutGenerate_ReportsNestedModelNotGenerated(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [NestedModel] NotAModel Model + ); + + public readonly record struct NotAModel; + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.NestedModelNotGenerated.Id); + } + + [Test] + public async Task NestedModelWithGenerate_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [NestedModel] OtherModel Model + ); + + [Generate(typeof(TestAttribute))] + public readonly record struct OtherModel(string? Mode); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task StringDefaultOnTypedConstant_ReportsDefaultValueNotSupported(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate("TestAttribute")] + public readonly record struct MyModel( + [Property("Test.Mode.Inherit", Name = "Mode")] + TypedConstant Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.DefaultValueNotSupported.Id); + } + + [Test] + public async Task NonNullableReferenceTypeWithoutDefault_ReportsRequiresDefault(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + string Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert + .That(result) + .HasDiagnostic(AttributeDataModelValidationAnalyzer.NonNullableReferenceTypeRequiresDefault.Id); + } + + [Test] + public async Task NullableReferenceType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task AutoDiscoverWithStringTarget_ReportsAutoDiscoverRequiresType(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate("TestAttribute", AutoDiscover = true)] + public readonly record struct MyModel; + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.AutoDiscoverRequiresType.Id); + } + + [Test] + public async Task AutoDiscoverWithTypeTarget_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute), AutoDiscover = true)] + public readonly record struct MyModel; + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task TypeArgumentWithNonTypeIdentityType_ReportsTypeArgumentPropertyTypeInvalid( + CancellationToken cancellationToken + ) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [GenericTypeArgument] string? TypeArgument + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert + .That(result) + .HasDiagnostic(AttributeDataModelValidationAnalyzer.TypeArgumentPropertyTypeInvalid.Id); + } + + [Test] + public async Task TypeArgumentWithTypeIdentityType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [GenericTypeArgument] TypeIdentity TypeArgument + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task IsEnumWithNonStringType_ReportsIsEnumRequiresStringType(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [Property(IsEnum = true)] int Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(AttributeDataModelValidationAnalyzer.IsEnumRequiresStringType.Id); + } + + [Test] + public async Task IsEnumWithStringType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + [Property(IsEnum = true)] string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task ValidModel_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var source = + AttributeDefinition + + """ + [Generate(typeof(TestAttribute))] + public readonly record struct MyModel( + bool Enabled, + string? Mode + ); + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) + { + var updatedOptions = options with { NullableContextOptions = NullableContextOptions.Enable }; + return base.OnBeforeRun( + sources, + updatedOptions.WithAdditionalAssemblyTypes(typeof(ISymbol)), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorCacheTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorCacheTests.cs new file mode 100644 index 0000000..2930593 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorCacheTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.Generators.Helpers; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +namespace Purview.SourceGeneratorFramework.Generators; + +/// +/// Proves the pipeline caches correctly stage-by-stage, mirroring the +/// example generator's ServiceRegistrationCacheTests. +/// +public class AttributeDataModelGeneratorCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test; + + [Generate(typeof(System.Attribute))] + public readonly partial record struct AttributeData(bool Enabled); + """; + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) + { + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + { + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + } + + return builder.ToImmutable(); + } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [new IncrementalRunInput([Source])], + cancellationToken: cancellationToken + ); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(static r => r).All(static r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second).IsNotEmpty(); + + // The generator's own pipeline stages must all be cached or unchanged. (Roslyn's internal + // ForAttributeWithMetadataName steps can report Modified on rerun because the post-initialization + // attribute source is regenerated as a new tree.) + string[] frameworkStages = + [ + "GetAttributeDataTargets", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + ]; + + await Assert + .That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(static r => r is StepReason.Cached or StepReason.Unchanged) + ) + ) + .IsTrue(); + } + + [Test] + public async Task ConfigChange_MarksConfigurationStagesModified_AttributeStageStaysCached( + CancellationToken cancellationToken + ) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput( + [Source], + [ + ( + SourceGeneratorBuildProperties.BuildProperty + + PropertyLibrary.DisableAttributeDataSourceGenerator, + "true" + ), + ] + ), + ], + cancellationToken: cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["GetGenerationConfiguration"]).Contains(StepReason.Modified); + await Assert + .That(second["GetAttributeDataTargets"].All(static r => r is StepReason.Cached or StepReason.Unchanged)) + .IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_ConfigurationStageStaysCached( + CancellationToken cancellationToken + ) + { + const string changedSource = """ + using Purview.SourceGeneratorFramework.Generators; + + namespace Test; + + [Generate(typeof(System.Attribute))] + public readonly partial record struct OtherAttributeData(bool Enabled, string? Name); + """; + + var result = await GenerateIncrementalAsync( + [new IncrementalRunInput([Source]), new IncrementalRunInput([changedSource])], + cancellationToken: cancellationToken + ); + + var second = StepReasons(result.Runs[1]); + + await Assert.That(second["GetAttributeDataTargets"]).Contains(StepReason.Modified); + await Assert.That(second["GetGenerationConfiguration"].All(static r => r == StepReason.Cached)).IsTrue(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs index be2aa8c..ac41023 100644 --- a/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.Generators.UnitTests/AttributeDataModelGeneratorTests.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using Purview.SourceGeneratorFramework.Generators.Helpers; namespace Purview.SourceGeneratorFramework.Generators; @@ -278,7 +277,7 @@ await Assert } [Test] - public async Task Generate_NestedModelNotGenerated_ReportsDiagnostic(CancellationToken cancellationToken) + public async Task Generate_NestedModelNotGenerated_SkipsGeneration(CancellationToken cancellationToken) { var source = """ using Purview.SourceGeneratorFramework.Generators; @@ -297,7 +296,15 @@ [NestedModel] NotAModel NotAModel var result = await GenerateAsync(source, cancellationToken: cancellationToken); - await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NestedModelNotGenerated); + // ADM0004 is reported by the analyzer; the generator only skips processing the invalid model. + await Assert.That(result.DriverResult.Diagnostics).DoesNotContain(d => d.Id == "ADM0004"); + + var generated = await GetGeneratedStringAsync( + result, + "RequiredAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + await Assert.That(generated).IsNull(); } [Test] @@ -334,7 +341,7 @@ await Assert } [Test] - public async Task Generate_StringTarget_WithAutoDiscover_ReportsDiagnostic(CancellationToken cancellationToken) + public async Task Generate_StringTarget_WithAutoDiscover_SkipsGeneration(CancellationToken cancellationToken) { var source = """ using Purview.SourceGeneratorFramework.Generators; @@ -349,7 +356,15 @@ namespace Test var result = await GenerateAsync(source, cancellationToken: cancellationToken); - await Assert.That(result.DriverResult.Diagnostics).Contains(d => d.Id == "ADM0007"); + // ADM0007 is reported by the analyzer; the generator only skips processing the invalid model. + await Assert.That(result.DriverResult.Diagnostics).DoesNotContain(d => d.Id == "ADM0007"); + + var generated = await GetGeneratedStringAsync( + result, + "RequiredAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + await Assert.That(generated).IsNull(); } [Test] @@ -397,9 +412,7 @@ await Assert } [Test] - public async Task Generate_TypedConstantWithStringDefault_ReportsUnsupportedDefaultDiagnostic( - CancellationToken cancellationToken - ) + public async Task Generate_TypedConstantWithStringDefault_SkipsGeneration(CancellationToken cancellationToken) { var source = """ using Microsoft.CodeAnalysis; @@ -418,7 +431,15 @@ TypedConstant Mode var result = await GenerateAsync(source, cancellationToken: cancellationToken); - await Assert.That(result.DriverResult.Diagnostics).Contains(d => d.Id == "ADM0005"); + // ADM0005 is reported by the analyzer; the generator only skips processing the invalid model. + await Assert.That(result.DriverResult.Diagnostics).DoesNotContain(d => d.Id == "ADM0005"); + + var generated = await GetGeneratedStringAsync( + result, + "TestAttributeData.AttributeDataModel.g.cs", + cancellationToken + ); + await Assert.That(generated).IsNull(); } [Test] diff --git a/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs b/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs index b96cd76..7a9f4d7 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/DiagnosticInfoTests.cs @@ -60,4 +60,46 @@ public async Task ToDiagnostic_WithLocation_CreatesDiagnosticWithLocation() await Assert.That(diagnostic.Location.SourceSpan.End).IsEqualTo(7); await Assert.That(diagnostic.Location.GetLineSpan().Path).IsEqualTo("Test.cs"); } + + [Test] + public async Task EqualDiagnostics_WithFreshArgumentArrays_CompareEqual() + { + var first = DiagnosticInfo.Create(TestDescriptor, (Location?)null, "first"); + var second = DiagnosticInfo.Create(TestDescriptor, (Location?)null, "first"); + + await Assert.That(first).IsEqualTo(second); + await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode()); + } + + [Test] + public async Task EqualDiagnostics_WithAdditionalLocations_CompareEqual() + { + var locations = new[] + { + Location.Create( + "File.cs", + new TextSpan(0, 5), + new LinePositionSpan(new LinePosition(0, 0), new LinePosition(0, 5)) + ), + Location.Create( + "File.cs", + new TextSpan(8, 3), + new LinePositionSpan(new LinePosition(1, 0), new LinePosition(1, 3)) + ), + }; + var first = DiagnosticInfo.Create(TestDescriptor, locations, "value"); + var second = DiagnosticInfo.Create(TestDescriptor, locations, "value"); + + await Assert.That(first).IsEqualTo(second); + await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode()); + } + + [Test] + public async Task DifferentMessageArgs_AreNotEqual() + { + var first = DiagnosticInfo.Create(TestDescriptor, (Location?)null, "first"); + var second = DiagnosticInfo.Create(TestDescriptor, (Location?)null, "second"); + + await Assert.That(first).IsNotEqualTo(second); + } } diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs index 7676530..98b3992 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/Models.cs @@ -1,10 +1,3 @@ -using System.Collections.Immutable; - namespace Purview.SourceGeneratorFramework.TestGenerators; readonly record struct TargetInfo(string Name); - -sealed record GenerationInputs(bool IsDisabled, string AssemblyName) -{ - public ImmutableArray Targets { get; init; } = []; -} diff --git a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs index e0953a9..280c6ca 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/TestGenerators/TestGenerator.cs @@ -19,29 +19,17 @@ public void Initialize(IncrementalGeneratorInitializationContext context) predicate: static (node, _) => node is Microsoft.CodeAnalysis.CSharp.Syntax.ClassDeclarationSyntax ); - var inputs = isDisabled - .CombineWith( - context.CompilationProvider.Select(static (compilation, _) => compilation.AssemblyName ?? "Unknown"), - static (disabled, assemblyName, _) => new GenerationInputs(disabled, assemblyName), - "CreateGenerationInputs" - ) - .CollectWith( - targets, - static (state, collectedTargets, _) => state with { Targets = collectedTargets }, - "AddGenerationTargets" - ); - + // Combine each target with the global disable flag so output stays per-item and only the + // affected target invalidates on change, rather than collecting everything into one aggregate. context.RegisterSourceOutput( - inputs, - static (spc, source) => + targets.CombineWith(isDisabled, static (target, disabled, _) => (target, disabled)), + static (spc, pair) => { - if (source.IsDisabled) + var (target, isDisabled) = pair; + if (isDisabled) return; - foreach (var target in source.Targets) - { - spc.AddSource($"{target.Name}.g.cs", $"partial class {target.Name} {{ }}"); - } + spc.AddSource($"{target.Name}.g.cs", $"partial class {target.Name} {{ }}"); } ); }