diff --git a/package-versions.props b/package-versions.props
index dea972acbb..94d62161bf 100644
--- a/package-versions.props
+++ b/package-versions.props
@@ -1,7 +1,7 @@
- 4.1.0
+ 4.13.0
0.4.1
2.14.1
13.0.4
@@ -24,6 +24,7 @@
0.9.*
14.6.*
13.0.*
+ 8.8.*
4.1.*
2.9.*
9.*-*
diff --git a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs
index 1b47821d22..ec8ff77a7d 100644
--- a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs
+++ b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs
@@ -2,167 +2,313 @@
using System.Text;
using Humanizer;
using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
-namespace JsonApiDotNetCore.SourceGenerators;
-// To debug in Visual Studio (requires v17.2 or higher):
+// To debug in Visual Studio (requires v17.13 or higher):
// - Set JsonApiDotNetCore.SourceGenerators as startup project
-// - Add a breakpoint at the start of the Initialize or Execute method
+// - Add a breakpoint at the start of the Initialize method
// - Optional: change targetProject in Properties\launchSettings.json
// - Press F5
+#pragma warning disable format
+
+namespace JsonApiDotNetCore.SourceGenerators;
+
[Generator(LanguageNames.CSharp)]
-public sealed class ControllerSourceGenerator : ISourceGenerator
+public sealed class ControllerSourceGenerator : IIncrementalGenerator
{
+ private const string ResourceAttributeName = "ResourceAttribute";
+ private const string ResourceAttributeFullName = $"JsonApiDotNetCore.Resources.Annotations.{ResourceAttributeName}";
+ private const string IdentifiableInterfaceName = "IIdentifiable";
+ private const string IdentifiableOpenGenericInterfaceName = "JsonApiDotNetCore.Resources.IIdentifiable";
+
private const string Category = "JsonApiDotNetCore";
private static readonly DiagnosticDescriptor MissingInterfaceWarning = new("JADNC001", "Resource type does not implement IIdentifiable",
"Type '{0}' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers", Category, DiagnosticSeverity.Warning,
true);
- private static readonly DiagnosticDescriptor MissingIndentInTableError = new("JADNC900", "Internal error: Insufficient entries in IndentTable",
- "Internal error: Missing entry in IndentTable for depth {0}", Category, DiagnosticSeverity.Warning, true);
+#pragma warning disable RS1035 // Do not use APIs banned for analyzers
+ private static readonly string LineBreak = Environment.NewLine;
+#pragma warning restore RS1035 // Do not use APIs banned for analyzers
+
+ public bool RaiseErrorForTesting { get; init; }
- // PERF: Heap-allocate the delegate only once, instead of per compilation.
- private static readonly SyntaxReceiverCreator CreateSyntaxReceiver = static () => new TypeWithAttributeSyntaxReceiver();
+ // Based on perf tips at https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/.
- public void Initialize(GeneratorInitializationContext context)
+ public void Initialize(IncrementalGeneratorInitializationContext context)
{
- context.RegisterForSyntaxNotifications(CreateSyntaxReceiver);
+ // @formatter:keep_existing_linebreaks true
+
+ IncrementalValuesProvider nullableResultsProvider = context.SyntaxProvider
+ .ForAttributeWithMetadataName(ResourceAttributeFullName,
+ static (syntaxNode, _) => syntaxNode is ClassDeclarationSyntax or RecordDeclarationSyntax,
+ static (generatorContext, _) => TryGetSemanticTarget(generatorContext))
+ .WithTrackingName(TrackingNames.GetSemanticTarget);
+
+ IncrementalValuesProvider resultsProvider = nullableResultsProvider
+ .Where(static result => result is not null)
+ .Select(static (result, _) => result!.Value)
+ .WithTrackingName(TrackingNames.FilterNulls);
+
+ IncrementalValuesProvider diagnosticsProvider = resultsProvider
+ .Where(static result => result is { Diagnostic: not null })
+ .Select(static (result, _) => result.Diagnostic!.Value)
+ .WithTrackingName(TrackingNames.FilterDiagnostics);
+
+ context.RegisterSourceOutput(diagnosticsProvider,
+ static (context, diagnosticInfo) => ReportDiagnostic(diagnosticInfo, context));
+
+ IncrementalValuesProvider coreControllersProvider = resultsProvider
+ .Where(static result => result is { CoreController: not null })
+ .Select(static (result, _) => result.CoreController!.Value)
+ .WithTrackingName(TrackingNames.FilterCoreControllers);
+
+ IncrementalValuesProvider fullControllersProvider = coreControllersProvider
+ .Select(static (coreController, _) => EnrichController(coreController))
+ .WithTrackingName(TrackingNames.EnrichCoreControllers);
+
+ // Must ensure unique file names, see https://github.com/dotnet/roslyn/discussions/60272#discussioncomment-6053422.
+ IncrementalValueProvider> renameMappingProvider = fullControllersProvider
+ .Select(static (controller, _) => (controller.ControllerType, controller.HintFileName))
+ .Collect()
+ .Select(static (collection, _) => CreateRenameMapping(collection))
+ .WithComparer(ImmutableDictionaryEqualityComparer.Instance)
+ .WithTrackingName(TrackingNames.CreateRenameMapping);
+
+ IncrementalValuesProvider uniquelyNamedControllersProvider = fullControllersProvider
+ .Combine(renameMappingProvider)
+ .Select(static (tuple, _) => ApplyRenameMapping(tuple.Left, tuple.Right))
+ .WithTrackingName(TrackingNames.ApplyRenameMapping);
+
+ context.RegisterSourceOutput(uniquelyNamedControllersProvider,
+ (productionContext, controller) => GenerateCode(productionContext, in controller));
+
+ // @formatter:keep_existing_linebreaks restore
}
- public void Execute(GeneratorExecutionContext context)
+ private static SemanticResult? TryGetSemanticTarget(GeneratorAttributeSyntaxContext generatorContext)
{
- var receiver = (TypeWithAttributeSyntaxReceiver?)context.SyntaxReceiver;
+ if (generatorContext.TargetNode is not TypeDeclarationSyntax resourceTypeSyntax)
+ {
+ return null;
+ }
- if (receiver == null)
+ if (generatorContext.TargetSymbol is not INamedTypeSymbol resourceTypeSymbol)
{
- return;
+ return null;
}
- INamedTypeSymbol? resourceAttributeType = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.Annotations.ResourceAttribute");
- INamedTypeSymbol? identifiableOpenInterface = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.IIdentifiable`1");
- INamedTypeSymbol? loggerFactoryInterface = context.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.ILoggerFactory");
+ AttributeData? resourceAttribute = TryGetResourceAttribute(resourceTypeSymbol);
- if (resourceAttributeType == null || identifiableOpenInterface == null || loggerFactoryInterface == null)
+ if (resourceAttribute == null)
{
- return;
+ return null;
}
- var controllerNamesInUse = new Dictionary(StringComparer.OrdinalIgnoreCase);
- var writer = new SourceCodeWriter(context, MissingIndentInTableError);
+ ITypeSymbol? idTypeSymbol = TryGetIdTypeSymbol(resourceTypeSymbol);
- foreach (TypeDeclarationSyntax? typeDeclarationSyntax in receiver.TypeDeclarations)
+ if (idTypeSymbol == null)
{
- // PERF: Note that our code runs on every keystroke in the IDE, which makes it critical to provide near-realtime performance.
- // This means keeping an eye on memory allocations and bailing out early when compilations are cancelled while the user is still typing.
- context.CancellationToken.ThrowIfCancellationRequested();
+ return CreateDiagnosticForMissingInterface(resourceTypeSyntax);
+ }
- SemanticModel semanticModel = context.Compilation.GetSemanticModel(typeDeclarationSyntax.SyntaxTree);
- INamedTypeSymbol? resourceType = semanticModel.GetDeclaredSymbol(typeDeclarationSyntax, context.CancellationToken);
+ (JsonApiEndpointsCopy endpoints, string? controllerNamespace) = GetResourceAttributeArguments(resourceAttribute);
- if (resourceType == null)
- {
- continue;
- }
+ if (endpoints == JsonApiEndpointsCopy.None)
+ {
+ return null;
+ }
- AttributeData? resourceAttributeData = FirstOrDefault(resourceType.GetAttributes(), resourceAttributeType,
- static (data, type) => SymbolEqualityComparer.Default.Equals(data.AttributeClass, type));
+ controllerNamespace ??= GetControllerNamespace(resourceTypeSymbol);
+ CoreControllerInfo? controllerInfo = CoreControllerInfo.TryCreate(resourceTypeSymbol, idTypeSymbol, endpoints, controllerNamespace);
- if (resourceAttributeData == null)
+ return new SemanticResult(controllerInfo, null);
+ }
+
+ private static AttributeData? TryGetResourceAttribute(INamedTypeSymbol typeSymbol)
+ {
+ foreach (AttributeData attribute in typeSymbol.GetAttributes())
+ {
+ if (attribute.AttributeClass?.Name == ResourceAttributeName && attribute.AttributeClass.ToDisplayString() == ResourceAttributeFullName)
{
- continue;
+ return attribute;
}
+ }
+
+ return null;
+ }
- TypedConstant endpointsArgument =
- resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "GenerateControllerEndpoints").Value;
+ private static ITypeSymbol? TryGetIdTypeSymbol(INamedTypeSymbol typeSymbol)
+ {
+ // This may look very expensive. However, measurements indicate that when starting from syntax, followed by resolving the symbol
+ // from the semantic model, it actually takes a dozen milliseconds longer to execute.
- if (endpointsArgument.Value != null && (JsonApiEndpointsCopy)endpointsArgument.Value == JsonApiEndpointsCopy.None)
+ foreach (INamedTypeSymbol interfaceSymbol in typeSymbol.AllInterfaces)
+ {
+ if (interfaceSymbol.IsGenericType && interfaceSymbol.Name == IdentifiableInterfaceName &&
+ interfaceSymbol.ConstructedFrom.ToDisplayString() == IdentifiableOpenGenericInterfaceName)
{
- continue;
+ return interfaceSymbol.TypeArguments[0];
}
+ }
- TypedConstant controllerNamespaceArgument =
- resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "ControllerNamespace").Value;
+ return null;
+ }
- string? controllerNamespace = GetControllerNamespace(controllerNamespaceArgument, resourceType);
+ private static SemanticResult CreateDiagnosticForMissingInterface(TypeDeclarationSyntax resourceTypeSyntax)
+ {
+ LocationInfo? location = LocationInfo.TryCreateFrom(resourceTypeSyntax);
+ return new SemanticResult(null, new MissingInterfaceDiagnostic(resourceTypeSyntax.Identifier.ValueText, location));
+ }
- INamedTypeSymbol? identifiableClosedInterface = FirstOrDefault(resourceType.AllInterfaces, identifiableOpenInterface,
- static (@interface, openInterface) =>
- @interface.IsGenericType && SymbolEqualityComparer.Default.Equals(@interface.ConstructedFrom, openInterface));
+ private static (JsonApiEndpointsCopy endpoints, string? controllerNamespace) GetResourceAttributeArguments(AttributeData attribute)
+ {
+ var endpoints = JsonApiEndpointsCopy.All;
+ string? controllerNamespace = null;
- if (identifiableClosedInterface == null)
+ if (attribute.NamedArguments is { IsEmpty: false } namedArguments)
+ {
+ foreach ((string argumentName, TypedConstant argumentValue) in namedArguments)
{
- var diagnostic = Diagnostic.Create(MissingInterfaceWarning, typeDeclarationSyntax.GetLocation(), resourceType.Name);
- context.ReportDiagnostic(diagnostic);
- continue;
+ switch (argumentName)
+ {
+ case "GenerateControllerEndpoints":
+ {
+ if (argumentValue.Kind is TypedConstantKind.Enum && argumentValue.Value is int enumValue)
+ {
+ endpoints = (JsonApiEndpointsCopy)enumValue;
+ }
+
+ break;
+ }
+ case "ControllerNamespace":
+ {
+ if (argumentValue.Kind is TypedConstantKind.Primitive && argumentValue.Value is string stringValue)
+ {
+ controllerNamespace = stringValue;
+ }
+
+ break;
+ }
+ }
}
+ }
- ITypeSymbol idType = identifiableClosedInterface.TypeArguments[0];
- string controllerName = $"{resourceType.Name.Pluralize()}Controller";
- JsonApiEndpointsCopy endpointsToGenerate = (JsonApiEndpointsCopy?)(int?)endpointsArgument.Value ?? JsonApiEndpointsCopy.All;
+ return (endpoints, controllerNamespace);
+ }
- string sourceCode = writer.Write(resourceType, idType, endpointsToGenerate, controllerNamespace, controllerName, loggerFactoryInterface);
- SourceText sourceText = SourceText.From(sourceCode, Encoding.UTF8);
+ private static string GetControllerNamespace(INamedTypeSymbol resourceType)
+ {
+ INamespaceSymbol? parentNamespace = resourceType.ContainingNamespace;
- string fileName = GetUniqueFileName(controllerName, controllerNamesInUse);
- context.AddSource(fileName, sourceText);
+ if (parentNamespace == null || parentNamespace.IsGlobalNamespace)
+ {
+ return string.Empty;
}
+
+ INamespaceSymbol? parentParentNamespace = parentNamespace.ContainingNamespace;
+ return parentParentNamespace.IsGlobalNamespace ? "Controllers" : $"{parentParentNamespace}.Controllers";
}
- private static TElement? FirstOrDefault(ImmutableArray source, TContext context, Func predicate)
+ private static void ReportDiagnostic(MissingInterfaceDiagnostic diagnosticInfo, SourceProductionContext context)
{
- // PERF: Using this method enables to avoid allocating a closure in the passed lambda expression.
- // See https://www.jetbrains.com/help/resharper/2021.2/Fixing_Issues_Found_by_DPA.html#closures-in-lambda-expressions.
+ var location = diagnosticInfo.Location?.ToLocation();
+ var diagnostic = Diagnostic.Create(MissingInterfaceWarning, location, diagnosticInfo.ResourceTypeName);
+ context.ReportDiagnostic(diagnostic);
+ }
+
+ private static FullControllerInfo EnrichController(CoreControllerInfo coreController)
+ {
+ // Pluralize() is an expensive call.
+ string controllerTypeName = $"{coreController.ResourceType.TypeName.Pluralize()}Controller";
+
+ return FullControllerInfo.Create(coreController, controllerTypeName);
+ }
- foreach (TElement element in source)
+ private static ImmutableDictionary CreateRenameMapping(ImmutableArray<(TypeInfo ControllerType, string HintFileName)> collection)
+ {
+ var namesInUse = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var renameMapping = ImmutableDictionary.Empty;
+
+ foreach ((TypeInfo controllerType, string hintFileName) in collection.OrderBy(static element => element.ControllerType.ToString(),
+ StringComparer.Ordinal))
{
- if (predicate(element, context))
+#pragma warning disable AV1532 // Loop statement contains nested loop
+ // Justification: optimized for performance.
+ for (int index = -1;; index++)
+#pragma warning restore AV1532 // Loop statement contains nested loop
{
- return element;
+ if (index == -1)
+ {
+ if (namesInUse.Add(hintFileName))
+ {
+ break;
+ }
+ }
+ else
+ {
+ string candidateName = $"{hintFileName}{index}";
+
+ if (namesInUse.Add(candidateName))
+ {
+ renameMapping = renameMapping.Add(controllerType, candidateName);
+ break;
+ }
+ }
}
}
- return default;
+ return renameMapping;
}
- private static string? GetControllerNamespace(TypedConstant controllerNamespaceArgument, INamedTypeSymbol resourceType)
+ private static FullControllerInfo ApplyRenameMapping(FullControllerInfo fullController, ImmutableDictionary renameMapping)
{
- if (!controllerNamespaceArgument.IsNull)
- {
- return (string?)controllerNamespaceArgument.Value;
- }
+ return renameMapping.TryGetValue(fullController.ControllerType, out string? replacementHintName)
+ ? fullController.WithHintFileName(replacementHintName)
+ : fullController;
+ }
+
+ private void GenerateCode(SourceProductionContext productionContext, in FullControllerInfo fullController)
+ {
+ SourceCodeWriter writer = new();
+ string fileContent;
- if (resourceType.ContainingNamespace.IsGlobalNamespace)
+ try
{
- return null;
- }
+ if (RaiseErrorForTesting)
+ {
+ throw new InvalidOperationException("Test error.");
+ }
- if (resourceType.ContainingNamespace.ContainingNamespace.IsGlobalNamespace)
+ fileContent = writer.Write(in fullController);
+ }
+#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException
+ catch (Exception exception)
+#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException
{
- return "Controllers";
+ fileContent = GetErrorText(exception, in fullController);
}
- return $"{resourceType.ContainingNamespace.ContainingNamespace}.Controllers";
+ string hintName = $"{fullController.HintFileName}.g.cs";
+ SourceText sourceText = SourceText.From(fileContent, Encoding.UTF8);
+ productionContext.AddSource(hintName, sourceText);
}
- private static string GetUniqueFileName(string controllerName, IDictionary controllerNamesInUse)
+ private static string GetErrorText(Exception exception, in FullControllerInfo fullController)
{
- // We emit unique file names to prevent a failure in the source generator, but also because our test suite
- // may contain two resources with the same class name in different namespaces. That works, as long as only
- // one of its controllers gets registered.
+ var builder = new StringBuilder();
+ builder.AppendLine($"#error Unhandled exception while generating controller class for type '{fullController.CoreController.ResourceType}'.");
+ builder.AppendLine();
+ builder.AppendLine($"// Input: {fullController}");
+ builder.AppendLine();
- if (controllerNamesInUse.TryGetValue(controllerName, out int lastIndex))
+ foreach (string errorLine in exception.ToString().Split(LineBreak))
{
- lastIndex++;
- controllerNamesInUse[controllerName] = lastIndex;
-
- return $"{controllerName}{lastIndex}.g.cs";
+ builder.AppendLine($"// {errorLine}");
}
- controllerNamesInUse[controllerName] = 1;
- return $"{controllerName}.g.cs";
+ return builder.ToString();
}
}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/CoreControllerInfo.cs b/src/JsonApiDotNetCore.SourceGenerators/CoreControllerInfo.cs
new file mode 100644
index 0000000000..2fe0ae3eaf
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/CoreControllerInfo.cs
@@ -0,0 +1,33 @@
+using Microsoft.CodeAnalysis;
+
+namespace JsonApiDotNetCore.SourceGenerators;
+
+///
+/// Basic outcome from the code analysis.
+///
+internal readonly record struct CoreControllerInfo(
+ TypeInfo ResourceType, TypeInfo IdType, string ControllerNamespace, JsonApiEndpointsCopy Endpoints, bool WriteNullableEnable)
+{
+ // Using readonly fields, so they can be passed by reference (using 'in' modifier, to avoid making copies) during code generation.
+ public readonly TypeInfo ResourceType = ResourceType;
+ public readonly TypeInfo IdType = IdType;
+ public readonly string ControllerNamespace = ControllerNamespace;
+ public readonly JsonApiEndpointsCopy Endpoints = Endpoints;
+ public readonly bool WriteNullableEnable = WriteNullableEnable;
+
+ public static CoreControllerInfo? TryCreate(INamedTypeSymbol resourceTypeSymbol, ITypeSymbol idTypeSymbol, JsonApiEndpointsCopy endpoints,
+ string controllerNamespace)
+ {
+ TypeInfo? resourceTypeInfo = TypeInfo.CreateFromQualified(resourceTypeSymbol);
+ TypeInfo? idTypeInfo = TypeInfo.TryCreateFromQualifiedOrPossiblyNullableKeyword(idTypeSymbol);
+
+ if (idTypeInfo == null)
+ {
+ return null;
+ }
+
+ bool writeNullableEnable = idTypeSymbol is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated };
+
+ return new CoreControllerInfo(resourceTypeInfo.Value, idTypeInfo.Value, controllerNamespace, endpoints, writeNullableEnable);
+ }
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/FullControllerInfo.cs b/src/JsonApiDotNetCore.SourceGenerators/FullControllerInfo.cs
new file mode 100644
index 0000000000..c11c44b4c5
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/FullControllerInfo.cs
@@ -0,0 +1,29 @@
+namespace JsonApiDotNetCore.SourceGenerators;
+
+///
+/// Supplemental information that is derived from the core analysis, which is expensive to produce.
+///
+internal readonly record struct FullControllerInfo(
+ CoreControllerInfo CoreController, TypeInfo ControllerType, TypeInfo LoggerFactoryInterface, string HintFileName)
+{
+ // Using readonly fields, so they can be passed by reference (using 'in' modifier, to avoid making copies) during code generation.
+ public readonly CoreControllerInfo CoreController = CoreController;
+ public readonly TypeInfo ControllerType = ControllerType;
+ public readonly TypeInfo LoggerFactoryInterface = LoggerFactoryInterface;
+ public readonly string HintFileName = HintFileName;
+
+ public static FullControllerInfo Create(CoreControllerInfo coreController, string controllerTypeName)
+ {
+ var controllerTypeInfo = new TypeInfo(coreController.ControllerNamespace, controllerTypeName);
+ var loggerFactoryTypeInfo = new TypeInfo("Microsoft.Extensions.Logging", "ILoggerFactory");
+
+ return new FullControllerInfo(coreController, controllerTypeInfo, loggerFactoryTypeInfo, controllerTypeName);
+ }
+
+ public FullControllerInfo WithHintFileName(string hintFileName)
+ {
+ // ReSharper disable once UseWithExpressionToCopyRecord
+ // Justification: Workaround for bug at https://youtrack.jetbrains.com/issue/RSRP-502017/Invalid-suggestion-to-use-with-expression.
+ return new FullControllerInfo(CoreController, ControllerType, LoggerFactoryInterface, hintFileName);
+ }
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/ImmutableDictionaryEqualityComparer.cs b/src/JsonApiDotNetCore.SourceGenerators/ImmutableDictionaryEqualityComparer.cs
new file mode 100644
index 0000000000..a77c5ed1c5
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/ImmutableDictionaryEqualityComparer.cs
@@ -0,0 +1,45 @@
+using System.Collections.Immutable;
+
+namespace JsonApiDotNetCore.SourceGenerators;
+
+// This type was copied from Roslyn. The implementation looks odd, but is likely a performance tradeoff.
+// Beware that the consuming code doesn't adhere to the typical pattern where a dictionary is built once, then queried many times.
+
+internal sealed class ImmutableDictionaryEqualityComparer : IEqualityComparer?>
+ where TKey : notnull
+{
+ public static readonly ImmutableDictionaryEqualityComparer Instance = new();
+
+ public bool Equals(ImmutableDictionary? x, ImmutableDictionary? y)
+ {
+ if (ReferenceEquals(x, y))
+ {
+ return true;
+ }
+
+ if (x is null || y is null)
+ {
+ return false;
+ }
+
+ if (!Equals(x.KeyComparer, y.KeyComparer) || !Equals(x.ValueComparer, y.ValueComparer))
+ {
+ return false;
+ }
+
+ foreach ((TKey key, TValue value) in x)
+ {
+ if (!y.TryGetValue(key, out TValue? other) || !x.ValueComparer.Equals(value, other))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public int GetHashCode(ImmutableDictionary? obj)
+ {
+ return obj?.Count ?? 0;
+ }
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj
index db6f039bd1..5e64066d23 100644
--- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj
+++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj
@@ -4,8 +4,9 @@
true
true
false
- $(NoWarn);NU5128
+ $(NoWarn);NU5128;RS2008
true
+ true
@@ -47,5 +48,6 @@
+
diff --git a/src/JsonApiDotNetCore.SourceGenerators/LocationInfo.cs b/src/JsonApiDotNetCore.SourceGenerators/LocationInfo.cs
new file mode 100644
index 0000000000..1b4b0efd1f
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/LocationInfo.cs
@@ -0,0 +1,32 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace JsonApiDotNetCore.SourceGenerators;
+
+internal readonly record struct LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
+{
+ public static LocationInfo? TryCreateFrom(SyntaxNode node)
+ {
+ return TryCreateFrom(node.GetLocation());
+ }
+
+ private static LocationInfo? TryCreateFrom(Location location)
+ {
+ if (location.SourceTree is null)
+ {
+ return null;
+ }
+
+ return new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span);
+ }
+
+ public Location ToLocation()
+ {
+ return Location.Create(FilePath, TextSpan, LineSpan);
+ }
+
+ public override string ToString()
+ {
+ return ToLocation().ToString();
+ }
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/MissingInterfaceDiagnostic.cs b/src/JsonApiDotNetCore.SourceGenerators/MissingInterfaceDiagnostic.cs
new file mode 100644
index 0000000000..3a3257154f
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/MissingInterfaceDiagnostic.cs
@@ -0,0 +1,3 @@
+namespace JsonApiDotNetCore.SourceGenerators;
+
+internal readonly record struct MissingInterfaceDiagnostic(string ResourceTypeName, LocationInfo? Location);
diff --git a/src/JsonApiDotNetCore.SourceGenerators/SemanticResult.cs b/src/JsonApiDotNetCore.SourceGenerators/SemanticResult.cs
new file mode 100644
index 0000000000..12fd777ebb
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/SemanticResult.cs
@@ -0,0 +1,3 @@
+namespace JsonApiDotNetCore.SourceGenerators;
+
+internal readonly record struct SemanticResult(CoreControllerInfo? CoreController, MissingInterfaceDiagnostic? Diagnostic);
diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs
index 3df1092c4b..99c54c8593 100644
--- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs
+++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs
@@ -1,12 +1,11 @@
using System.Text;
-using Microsoft.CodeAnalysis;
namespace JsonApiDotNetCore.SourceGenerators;
///
/// Writes the source code for an ASP.NET controller for a JSON:API resource.
///
-internal sealed class SourceCodeWriter(GeneratorExecutionContext context, DiagnosticDescriptor missingIndentInTableErrorDescriptor)
+internal sealed class SourceCodeWriter
{
private const int SpacesPerIndent = 4;
@@ -39,36 +38,29 @@ internal sealed class SourceCodeWriter(GeneratorExecutionContext context, Diagno
[JsonApiEndpointsCopy.DeleteRelationship] = ("IRemoveFromRelationshipService", "removeFromRelationship")
};
- private readonly GeneratorExecutionContext _context = context;
- private readonly DiagnosticDescriptor _missingIndentInTableErrorDescriptor = missingIndentInTableErrorDescriptor;
-
private readonly StringBuilder _sourceBuilder = new();
private int _depth;
- public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEndpointsCopy endpointsToGenerate, string? controllerNamespace,
- string controllerName, INamedTypeSymbol loggerFactoryInterface)
+ public string Write(in FullControllerInfo fullController)
{
_sourceBuilder.Clear();
_depth = 0;
WriteAutoGeneratedComment();
- if (idType is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated })
+ if (fullController.CoreController.WriteNullableEnable)
{
WriteNullableEnable();
}
- WriteNamespaceImports(loggerFactoryInterface, resourceType, controllerNamespace);
+ WriteNamespaceImports(in fullController);
- if (controllerNamespace != null)
- {
- WriteNamespaceDeclaration(controllerNamespace);
- }
+ WriteNamespaceDeclaration(fullController.ControllerType.Namespace);
- WriteOpenClassDeclaration(controllerName, endpointsToGenerate, resourceType, idType);
+ WriteOpenClassDeclaration(in fullController);
_depth++;
- WriteConstructor(controllerName, loggerFactoryInterface, endpointsToGenerate, resourceType, idType);
+ WriteConstructor(in fullController);
_depth--;
WriteCloseCurly();
@@ -88,39 +80,61 @@ private void WriteNullableEnable()
_sourceBuilder.AppendLine();
}
- private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace)
+ private void WriteNamespaceImports(in FullControllerInfo fullController)
{
- _sourceBuilder.AppendLine($"using {loggerFactoryInterface.ContainingNamespace};");
+ SortedSet namespaces =
+ [
+ "JsonApiDotNetCore.Configuration",
+ "JsonApiDotNetCore.Controllers",
+ "JsonApiDotNetCore.Services"
+ ];
+
+ AddTypeToNamespaceImports(in fullController.LoggerFactoryInterface, namespaces);
+ AddTypeToNamespaceImports(in fullController.CoreController.ResourceType, namespaces);
+ AddTypeToNamespaceImports(in fullController.CoreController.IdType, namespaces);
+ namespaces.Remove(fullController.ControllerType.Namespace);
+
+ if (namespaces.Count > 0)
+ {
+ foreach (string @namespace in namespaces)
+ {
+ _sourceBuilder.AppendLine($"using {@namespace};");
+ }
- _sourceBuilder.AppendLine("using JsonApiDotNetCore.Configuration;");
- _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;");
- _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;");
+ _sourceBuilder.AppendLine();
+ }
+ }
- if (!resourceType.ContainingNamespace.IsGlobalNamespace && resourceType.ContainingNamespace.ToString() != controllerNamespace)
+ private static void AddTypeToNamespaceImports(in TypeInfo type, SortedSet namespaces)
+ {
+ if (type.Namespace != string.Empty)
{
- _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};");
+ namespaces.Add(type.Namespace);
}
-
- _sourceBuilder.AppendLine();
}
private void WriteNamespaceDeclaration(string controllerNamespace)
{
- _sourceBuilder.AppendLine($"namespace {controllerNamespace};");
- _sourceBuilder.AppendLine();
+ if (controllerNamespace != string.Empty)
+ {
+ _sourceBuilder.AppendLine($"namespace {controllerNamespace};");
+ _sourceBuilder.AppendLine();
+ }
}
- private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType)
+ private void WriteOpenClassDeclaration(in FullControllerInfo fullController)
{
- string baseClassName = GetControllerBaseClassName(endpointsToGenerate);
+ string baseClassName = GetControllerBaseClassName(in fullController.CoreController.Endpoints);
WriteIndent();
- _sourceBuilder.AppendLine($"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>");
+
+ _sourceBuilder.AppendLine(
+ $"public sealed partial class {fullController.ControllerType.TypeName} : {baseClassName}<{fullController.CoreController.ResourceType.TypeName}, {fullController.CoreController.IdType.TypeName}>");
WriteOpenCurly();
}
- private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate)
+ private static string GetControllerBaseClassName(in JsonApiEndpointsCopy endpointsToGenerate)
{
return endpointsToGenerate switch
{
@@ -130,23 +144,24 @@ private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsT
};
}
- private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFactoryInterface, JsonApiEndpointsCopy endpointsToGenerate,
- INamedTypeSymbol resourceType, ITypeSymbol idType)
+ private void WriteConstructor(in FullControllerInfo fullController)
{
- string loggerName = loggerFactoryInterface.Name;
-
WriteIndent();
- _sourceBuilder.AppendLine($"public {controllerName}(IJsonApiOptions options, IResourceGraph resourceGraph, {loggerName} loggerFactory,");
+
+ _sourceBuilder.AppendLine(
+ $"public {fullController.ControllerType.TypeName}(IJsonApiOptions options, IResourceGraph resourceGraph, {fullController.LoggerFactoryInterface.TypeName} loggerFactory,");
_depth++;
- if (AggregateEndpointToServiceNameMap.TryGetValue(endpointsToGenerate, out (string ServiceName, string ParameterName) value))
+ if (AggregateEndpointToServiceNameMap.TryGetValue(fullController.CoreController.Endpoints, out (string ServiceName, string ParameterName) value))
{
- WriteParameterListForShortConstructor(value.ServiceName, value.ParameterName, resourceType, idType);
+ WriteParameterListForShortConstructor(value.ServiceName, value.ParameterName, fullController.CoreController.ResourceType.TypeName,
+ fullController.CoreController.IdType.TypeName);
}
else
{
- WriteParameterListForLongConstructor(endpointsToGenerate, resourceType, idType);
+ WriteParameterListForLongConstructor(in fullController.CoreController.Endpoints, fullController.CoreController.ResourceType.TypeName,
+ fullController.CoreController.IdType.TypeName);
}
_depth--;
@@ -155,22 +170,22 @@ private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFact
WriteCloseCurly();
}
- private void WriteParameterListForShortConstructor(string serviceName, string parameterName, INamedTypeSymbol resourceType, ITypeSymbol idType)
+ private void WriteParameterListForShortConstructor(string serviceName, string parameterName, string resourceTypeName, string idTypeName)
{
WriteIndent();
- _sourceBuilder.AppendLine($"{serviceName}<{resourceType.Name}, {idType}> {parameterName})");
+ _sourceBuilder.AppendLine($"{serviceName}<{resourceTypeName}, {idTypeName}> {parameterName})");
WriteIndent();
_sourceBuilder.AppendLine($": base(options, resourceGraph, loggerFactory, {parameterName})");
}
- private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType)
+ private void WriteParameterListForLongConstructor(in JsonApiEndpointsCopy endpoints, string resourceTypeName, string idTypeName)
{
bool isFirstEntry = true;
- foreach (KeyValuePair entry in EndpointToServiceNameMap)
+ foreach ((JsonApiEndpointsCopy endpoint, (string serviceName, string parameterName)) in EndpointToServiceNameMap)
{
- if ((endpointsToGenerate & entry.Key) == entry.Key)
+ if ((endpoints & endpoint) == endpoint)
{
if (isFirstEntry)
{
@@ -182,7 +197,7 @@ private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpoints
}
WriteIndent();
- _sourceBuilder.Append($"{entry.Value.ServiceName}<{resourceType.Name}, {idType}> {entry.Value.ParameterName}");
+ _sourceBuilder.Append($"{serviceName}<{resourceTypeName}, {idTypeName}> {parameterName}");
}
}
@@ -194,9 +209,9 @@ private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpoints
isFirstEntry = true;
_depth++;
- foreach (KeyValuePair entry in EndpointToServiceNameMap)
+ foreach ((JsonApiEndpointsCopy endpoint, (_, string parameterName)) in EndpointToServiceNameMap)
{
- if ((endpointsToGenerate & entry.Key) == entry.Key)
+ if ((endpoints & endpoint) == endpoint)
{
if (isFirstEntry)
{
@@ -208,7 +223,7 @@ private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpoints
}
WriteIndent();
- _sourceBuilder.Append($"{entry.Value.ParameterName}: {entry.Value.ParameterName}");
+ _sourceBuilder.Append($"{parameterName}: {parameterName}");
}
}
@@ -233,10 +248,7 @@ private void WriteIndent()
// PERF: Reuse pre-calculated indents instead of allocating a new string each time.
if (!IndentTable.TryGetValue(_depth, out string? indent))
{
- var diagnostic = Diagnostic.Create(_missingIndentInTableErrorDescriptor, Location.None, _depth.ToString());
- _context.ReportDiagnostic(diagnostic);
-
- indent = new string(' ', _depth * SpacesPerIndent);
+ throw new InvalidOperationException("Internal error: Insufficient entries in IndentTable.");
}
_sourceBuilder.Append(indent);
diff --git a/src/JsonApiDotNetCore.SourceGenerators/TrackingNames.cs b/src/JsonApiDotNetCore.SourceGenerators/TrackingNames.cs
new file mode 100644
index 0000000000..3c2405a022
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/TrackingNames.cs
@@ -0,0 +1,12 @@
+namespace JsonApiDotNetCore.SourceGenerators;
+
+public sealed class TrackingNames
+{
+ public const string GetSemanticTarget = nameof(GetSemanticTarget);
+ public const string FilterNulls = nameof(FilterNulls);
+ public const string FilterDiagnostics = nameof(FilterDiagnostics);
+ public const string FilterCoreControllers = nameof(FilterCoreControllers);
+ public const string EnrichCoreControllers = nameof(EnrichCoreControllers);
+ public const string CreateRenameMapping = nameof(CreateRenameMapping);
+ public const string ApplyRenameMapping = nameof(ApplyRenameMapping);
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/TypeInfo.cs b/src/JsonApiDotNetCore.SourceGenerators/TypeInfo.cs
new file mode 100644
index 0000000000..0ed738ca5f
--- /dev/null
+++ b/src/JsonApiDotNetCore.SourceGenerators/TypeInfo.cs
@@ -0,0 +1,66 @@
+using Microsoft.CodeAnalysis;
+
+namespace JsonApiDotNetCore.SourceGenerators;
+
+internal readonly record struct TypeInfo(string Namespace, string TypeName)
+{
+ // Uncomment to verify non-cached outputs are detected in tests.
+ //public readonly object Dummy = new();
+
+ // Uncomment to verify banned types are detected in tests.
+ //private static readonly SyntaxNode FrozenIdentifier = SyntaxFactory.IdentifierName("some");
+ //public readonly SyntaxNode Dummy = FrozenIdentifier;
+
+ public static TypeInfo CreateFromQualified(ITypeSymbol typeSymbol)
+ {
+ string @namespace = GetNamespace(typeSymbol);
+ return new TypeInfo(@namespace, typeSymbol.Name);
+ }
+
+ public static TypeInfo? TryCreateFromQualifiedOrPossiblyNullableKeyword(ITypeSymbol typeSymbol)
+ {
+ ITypeSymbol innerTypeSymbol = UnwrapNullableValueTypeOrSelf(typeSymbol);
+
+ if (innerTypeSymbol.Kind == SymbolKind.ErrorType)
+ {
+ return null;
+ }
+
+ if (innerTypeSymbol.SpecialType != SpecialType.None)
+ {
+ // Built-in types that don't need a namespace import, such as: int, long?, string, string?
+ return new TypeInfo(string.Empty, typeSymbol.ToString());
+ }
+
+ string @namespace = GetNamespace(innerTypeSymbol);
+ string typeName = !ReferenceEquals(innerTypeSymbol, typeSymbol) ? $"{innerTypeSymbol.Name}?" : innerTypeSymbol.Name;
+
+ // Fully-qualified types, such as: System.Guid, System.Guid?
+ return new TypeInfo(@namespace, typeName);
+ }
+
+ private static ITypeSymbol UnwrapNullableValueTypeOrSelf(ITypeSymbol typeSymbol)
+ {
+ if (typeSymbol is INamedTypeSymbol namedTypeSymbol)
+ {
+ if (namedTypeSymbol.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T && namedTypeSymbol.TypeArguments.Length == 1)
+ {
+ return namedTypeSymbol.TypeArguments[0];
+ }
+ }
+
+ return typeSymbol;
+ }
+
+ private static string GetNamespace(ITypeSymbol typeSymbol)
+ {
+ return typeSymbol.ContainingNamespace == null || typeSymbol.ContainingNamespace.IsGlobalNamespace
+ ? string.Empty
+ : typeSymbol.ContainingNamespace.ToString();
+ }
+
+ public override string ToString()
+ {
+ return Namespace.Length > 0 ? $"{Namespace}.{TypeName}" : TypeName;
+ }
+}
diff --git a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs
deleted file mode 100644
index 17c5ffefd0..0000000000
--- a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-
-namespace JsonApiDotNetCore.SourceGenerators;
-
-///
-/// Collects type declarations in the project that have at least one attribute on them. Because this receiver operates at the syntax level, we cannot
-/// check for the expected attribute. This must be done during semantic analysis, because source code may contain any of these:
-/// { }
-///
-/// [ResourceAttribute]
-/// public class ExampleResource2 : Identifiable { }
-///
-/// [AlternateNamespaceName.Annotations.Resource]
-/// public class ExampleResource3 : Identifiable { }
-///
-/// [AlternateTypeName]
-/// public class ExampleResource4 : Identifiable { }
-/// ]]>
-///
-internal sealed class TypeWithAttributeSyntaxReceiver : ISyntaxReceiver
-{
- public readonly ISet TypeDeclarations = new HashSet();
-
- public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
- {
- if (syntaxNode is TypeDeclarationSyntax { AttributeLists.Count: > 0 } typeDeclarationSyntax)
- {
- TypeDeclarations.Add(typeDeclarationSyntax);
- }
- }
-}
diff --git a/test/SourceGeneratorTests/CompilationBuilder.cs b/test/SourceGeneratorTests/CompilationBuilder.cs
index 90c0d6e396..e543c56e31 100644
--- a/test/SourceGeneratorTests/CompilationBuilder.cs
+++ b/test/SourceGeneratorTests/CompilationBuilder.cs
@@ -12,8 +12,8 @@ internal sealed class CompilationBuilder
private static readonly CSharpCompilationOptions DefaultOptions =
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary).WithSpecificDiagnosticOptions(new Dictionary
{
- // Suppress warning for version conflict on Microsoft.Extensions.Logging.Abstractions:
- // JsonApiDotNetCore indirectly depends on v6 (via Entity Framework Core 6), whereas Entity Framework Core 7 depends on v7.
+ // Suppress warning for version conflict on Microsoft.AspNetCore.Mvc.Core:
+ // JsonApiDotNetCore indirectly depends on v8 (via FrameworkReference), whereas v9 is used when running tests on .NET 9.
["CS1701"] = ReportDiagnostic.Suppress
});
@@ -60,7 +60,7 @@ public CompilationBuilder WithLoggerFactoryReference()
return this;
}
- public CompilationBuilder WithJsonApiDotNetCoreReferences()
+ private void WithJsonApiDotNetCoreReferences()
{
foreach (PortableExecutableReference reference in new[]
{
@@ -71,8 +71,6 @@ public CompilationBuilder WithJsonApiDotNetCoreReferences()
{
_references.Add(reference);
}
-
- return this;
}
public CompilationBuilder WithSourceCode(string source)
diff --git a/test/SourceGeneratorTests/CompilationExtensions.cs b/test/SourceGeneratorTests/CompilationExtensions.cs
new file mode 100644
index 0000000000..28842f55e2
--- /dev/null
+++ b/test/SourceGeneratorTests/CompilationExtensions.cs
@@ -0,0 +1,154 @@
+using System.Collections;
+using System.Collections.Immutable;
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace SourceGeneratorTests;
+
+///
+/// Based on https://andrewlock.net/creating-a-source-generator-part-10-testing-your-incremental-generator-pipeline-outputs-are-cacheable/. Checks for
+/// banned types and verifies that outputs of pipeline stages are cached. Well, it actually only tests the FIRST stage, but at least it's something.
+///
+internal static class CompilationExtensions
+{
+ public static (ImmutableArray Diagnostics, string[] Output) AssertOutputsAreCached(this Compilation compilation,
+ IIncrementalGenerator generator, string[] trackingNames)
+ {
+ ISourceGenerator sourceGenerator = generator.AsSourceGenerator();
+
+ // Tell the driver to track all the incremental generator outputs.
+ // Without this, you'll have no tracked outputs!
+ var options = new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, true);
+
+ GeneratorDriver driver = CSharpGeneratorDriver.Create([sourceGenerator], driverOptions: options);
+
+ // Create a clone of the compilation that we will use later.
+ Compilation clone = compilation.Clone();
+
+ // Do the initial run. Note that we store the returned driver value, as it contains cached previous outputs.
+ driver = driver.RunGenerators(compilation);
+ GeneratorDriverRunResult runResult1 = driver.GetRunResult();
+
+ // Run again, using the same driver, with a clone of the compilation.
+ GeneratorDriverRunResult runResult2 = driver.RunGenerators(clone).GetRunResult();
+
+ // Compare all the tracked outputs, throw if there's a failure.
+ AssertRunsEqual(runResult1, runResult2, trackingNames);
+
+ // Verify the second run only generated cached source outputs.
+ runResult2.Results[0].TrackedOutputSteps.SelectMany(pair => pair.Value) // step executions
+ .SelectMany(step => step.Outputs) // execution results
+ .Should().OnlyContain(pair => pair.Reason == IncrementalStepRunReason.Cached);
+
+ // Return the generator diagnostics and generated sources.
+ return (runResult1.Diagnostics, runResult1.GeneratedTrees.Select(tree => tree.ToString()).ToArray());
+ }
+
+ private static void AssertRunsEqual(GeneratorDriverRunResult runResult1, GeneratorDriverRunResult runResult2, string[] trackingNames)
+ {
+ // We're given all the tracking names, but not all the stages will necessarily execute, so extract all the output steps, and filter to ones we know about.
+ Dictionary> trackedSteps1 = GetTrackedSteps(runResult1, trackingNames);
+ Dictionary> trackedSteps2 = GetTrackedSteps(runResult2, trackingNames);
+
+ // Both runs should have the same tracked steps.
+ trackedSteps1.Should().NotBeEmpty().And.HaveSameCount(trackedSteps2).And.ContainKeys(trackedSteps2.Keys);
+
+ // Get the IncrementalGeneratorRunStep collection for each run.
+ foreach ((string trackingName, ImmutableArray runSteps1) in trackedSteps1)
+ {
+ // Assert that both runs produced the same outputs.
+ ImmutableArray runSteps2 = trackedSteps2[trackingName];
+ AssertEqual(runSteps1, runSteps2, trackingName);
+ }
+
+ // Local function that extracts the tracked steps.
+ static Dictionary> GetTrackedSteps(GeneratorDriverRunResult runResult, string[] trackingNames)
+ {
+ return runResult.Results[0] // we're only running a single generator, so this is safe
+ .TrackedSteps // get the pipeline outputs
+ .Where(step => trackingNames.Contains(step.Key)) // filter to known steps
+ .ToDictionary(pair => pair.Key, pair => pair.Value); // convert to a dictionary
+ }
+ }
+
+ private static void AssertEqual(ImmutableArray runSteps1, ImmutableArray runSteps2,
+ string stepName)
+ {
+ runSteps1.Should().HaveSameCount(runSteps2);
+
+ for (int index = 0; index < runSteps1.Length; index++)
+ {
+ IncrementalGeneratorRunStep runStep1 = runSteps1[index];
+ IncrementalGeneratorRunStep runStep2 = runSteps2[index];
+
+ // The outputs should be equal between different runs.
+ IEnumerable