From 0fd9c7f5c528781ac7bacf5882a0a09514ac8900 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 7 Sep 2025 14:58:34 +0200 Subject: [PATCH] Switch to incremental source generator API --- package-versions.props | 3 +- .../ControllerSourceGenerator.cs | 318 ++++++--- .../CoreControllerInfo.cs | 33 + .../FullControllerInfo.cs | 29 + .../ImmutableDictionaryEqualityComparer.cs | 45 ++ .../JsonApiDotNetCore.SourceGenerators.csproj | 4 +- .../LocationInfo.cs | 32 + .../MissingInterfaceDiagnostic.cs | 3 + .../SemanticResult.cs | 3 + .../SourceCodeWriter.cs | 114 +-- .../TrackingNames.cs | 12 + .../TypeInfo.cs | 66 ++ .../TypeWithAttributeSyntaxReceiver.cs | 38 - .../CompilationBuilder.cs | 8 +- .../CompilationExtensions.cs | 154 ++++ .../ControllerGenerationTests.cs | 657 ++++++++++++++++-- .../PipelineStepCachingTests.cs | 93 +++ 17 files changed, 1388 insertions(+), 224 deletions(-) create mode 100644 src/JsonApiDotNetCore.SourceGenerators/CoreControllerInfo.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/FullControllerInfo.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/ImmutableDictionaryEqualityComparer.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/LocationInfo.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/MissingInterfaceDiagnostic.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/SemanticResult.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/TrackingNames.cs create mode 100644 src/JsonApiDotNetCore.SourceGenerators/TypeInfo.cs delete mode 100644 src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs create mode 100644 test/SourceGeneratorTests/CompilationExtensions.cs create mode 100644 test/SourceGeneratorTests/PipelineStepCachingTests.cs 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 outputs1 = runStep1.Outputs.Select(pair => pair.Value); + IEnumerable outputs2 = runStep2.Outputs.Select(pair => pair.Value); + + outputs1.Should().Equal(outputs2, $"because {stepName} should produce cacheable outputs"); + + // Therefore, on the second run the results should always be cached or unchanged! + // - Unchanged is when the _input_ has changed, but the output hasn't. + // - Cached is when the input has not changed, so the cached output is used. + runStep2.Outputs.Should().OnlyContain(pair => pair.Reason == IncrementalStepRunReason.Cached || pair.Reason == IncrementalStepRunReason.Unchanged, + $"{stepName} expected to have reason {IncrementalStepRunReason.Cached} or {IncrementalStepRunReason.Unchanged}"); + + // Make sure we're not using anything we shouldn't. + AssertObjectGraph(runStep1, stepName); + } + } + + private static void AssertObjectGraph(IncrementalGeneratorRunStep runStep, string stepName) + { + // Including the step name in error messages to make it easy to isolate issues. + string because = $"{stepName} shouldn't contain banned types"; + var visited = new HashSet(); + + // Check all the outputs - probably overkill, but why not. + foreach ((object obj, IncrementalStepRunReason _) in runStep.Outputs) + { + Visit(obj); + } + + void Visit(object? node) + { + // If we've already seen this object, or it's null, stop. + if (node is null || !visited.Add(node)) + { + return; + } + + // Make sure it's not a banned type. + node.Should().NotBeAssignableTo(because).And.NotBeAssignableTo(because).And.NotBeAssignableTo(because); + + // Examine the object. + Type type = node.GetType(); + + if (type.IsPrimitive || type.IsEnum || type == typeof(string)) + { + return; + } + + // If the object is a collection, check each of the values. + if (node is IEnumerable collection and not string) + { + foreach (object element in collection) + { + // Recursively check each element in the collection. + Visit(element); + } + } + else + { + // Recursively check each field in the object. + foreach (FieldInfo field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + object? fieldValue = field.GetValue(node); + Visit(fieldValue); + } + } + } + } +} diff --git a/test/SourceGeneratorTests/ControllerGenerationTests.cs b/test/SourceGeneratorTests/ControllerGenerationTests.cs index 098778dd0f..c046254565 100644 --- a/test/SourceGeneratorTests/ControllerGenerationTests.cs +++ b/test/SourceGeneratorTests/ControllerGenerationTests.cs @@ -12,7 +12,7 @@ namespace SourceGeneratorTests; public sealed class ControllerGenerationTests { [Fact] - public void Can_generate_for_default_controller() + public void Can_generate_for_default_controller_with_long_Id_type() { // Arrange GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); @@ -55,11 +55,537 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_nullable_long_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_Int32_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_nullable_Int32_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_Guid_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(Guid)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + using System; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_nullable_Guid_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(Guid)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + using System; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_string_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_default_controller_with_nullable_string_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + #nullable enable + + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + + #nullable enable + + using ExampleApi.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; + + namespace ExampleApi.Controllers; + + public sealed partial class ItemsController : JsonApiController + { + public ItemsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } + } + + """); + } + + [Fact] + public void Can_generate_for_record_type_controller() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + #nullable enable + + [Resource] + public sealed record Item : IIdentifiable + { + [Attr] + public int Value { get; set; } + + public string? StringId { get; set; } + public string? LocalId { get; set; } + public long Id { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().BeEmpty(); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + + runResult.Should().HaveProducedSourceCode(""" + // + using ExampleApi.Models; + using JsonApiDotNetCore.Configuration; + using JsonApiDotNetCore.Controllers; + using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; namespace ExampleApi.Controllers; @@ -120,11 +646,11 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; + using ExampleApi.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; - using ExampleApi.Models; + using Microsoft.Extensions.Logging; namespace ExampleApi.Controllers; @@ -185,11 +711,11 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; + using ExampleApi.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; - using ExampleApi.Models; + using Microsoft.Extensions.Logging; namespace ExampleApi.Controllers; @@ -253,11 +779,11 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; + using ExampleApi.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; - using ExampleApi.Models; + using Microsoft.Extensions.Logging; namespace ExampleApi.Controllers; @@ -369,7 +895,7 @@ public sealed class Item : Identifiable } [Fact] - public void Skips_for_missing_dependency_on_JsonApiDotNetCore() + public void Skips_for_missing_reference_to_JsonApiDotNetCore() { // Arrange GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); @@ -380,20 +906,52 @@ public void Skips_for_missing_dependency_on_JsonApiDotNetCore() string source = new SourceCodeBuilder() .InNamespace("ExampleApi.Models") .WithCode(""" - public abstract class Identifiable + [Resource] + public sealed class Item : Identifiable // Multiple types are unresolved because the assembly reference to JsonApiDotNetCore is missing. { + [Attr] + public int Value { get; set; } } + """) + .Build(); - public sealed class ResourceAttribute : System.Attribute - { - } + Compilation inputCompilation = new CompilationBuilder() + .WithSystemReferences() + .WithLoggerFactoryReference() + .WithSourceCode(source) + .Build(); - public sealed class AttrAttribute : System.Attribute - { - } + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + // Act + driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); + + // Assert + inputCompilation.GetDiagnostics().Should().HaveCount(5); + outputCompilation.GetDiagnostics().Should().HaveCount(5); + + GeneratorDriverRunResult runResult = driver.GetRunResult(); + runResult.Should().NotHaveDiagnostics(); + runResult.Should().NotHaveProducedSourceCode(); + } + + [Fact] + public void Skips_for_unresolved_Id_type() + { + // Arrange + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" [Resource] - public sealed class Item : Identifiable + public sealed class Item : Identifiable // Guid is unresolved because the namespace import for System is missing. { [Attr] public int Value { get; set; } @@ -402,8 +960,7 @@ public sealed class Item : Identifiable .Build(); Compilation inputCompilation = new CompilationBuilder() - .WithSystemReferences() - .WithLoggerFactoryReference() + .WithDefaultReferences() .WithSourceCode(source) .Build(); @@ -414,17 +971,16 @@ public sealed class Item : Identifiable driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); // Assert - inputCompilation.GetDiagnostics().Should().BeEmpty(); - outputCompilation.GetDiagnostics().Should().BeEmpty(); + inputCompilation.GetDiagnostics().Should().HaveCount(1); + outputCompilation.GetDiagnostics().Should().HaveCount(1); GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().NotHaveProducedSourceCode(); } [Fact] - public void Skips_for_missing_dependency_on_LoggerFactory() + public void Skips_for_unresolved_nullable_Id_type() { // Arrange GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); @@ -438,7 +994,7 @@ public void Skips_for_missing_dependency_on_LoggerFactory() .InNamespace("ExampleApi.Models") .WithCode(""" [Resource] - public sealed class Item : Identifiable + public sealed class Item : Identifiable // Guid? is unresolved because the namespace import for System is missing { [Attr] public int Value { get; set; } @@ -447,8 +1003,7 @@ public sealed class Item : Identifiable .Build(); Compilation inputCompilation = new CompilationBuilder() - .WithSystemReferences() - .WithJsonApiDotNetCoreReferences() + .WithDefaultReferences() .WithSourceCode(source) .Build(); @@ -459,12 +1014,11 @@ public sealed class Item : Identifiable driver = driver.RunGeneratorsAndUpdateCompilation(inputCompilation, out Compilation outputCompilation, out _); // Assert - inputCompilation.GetDiagnostics().Should().BeEmpty(); - outputCompilation.GetDiagnostics().Should().BeEmpty(); + inputCompilation.GetDiagnostics().Should().HaveCount(1); + outputCompilation.GetDiagnostics().Should().HaveCount(1); GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().NotHaveProducedSourceCode(); } @@ -514,10 +1068,13 @@ public sealed class Item } [Fact] - public void Adds_nullable_enable_for_nullable_reference_ID_type() + public void Captures_unhandled_exception_during_code_generation() { // Arrange - GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator()); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new ControllerSourceGenerator + { + RaiseErrorForTesting = true + }); // @formatter:wrap_chained_method_calls chop_always // @formatter:wrap_before_first_method_call true @@ -527,10 +1084,8 @@ public void Adds_nullable_enable_for_nullable_reference_ID_type() .WithNamespaceImportFor(typeof(ResourceAttribute)) .InNamespace("ExampleApi.Models") .WithCode(""" - #nullable enable - [Resource] - public sealed class Item : Identifiable + public sealed class Item : Identifiable { [Attr] public int Value { get; set; } @@ -551,12 +1106,19 @@ public sealed class Item : Identifiable // Assert inputCompilation.GetDiagnostics().Should().BeEmpty(); - outputCompilation.GetDiagnostics().Should().BeEmpty(); + outputCompilation.GetDiagnostics().Should().NotBeEmpty(); GeneratorDriverRunResult runResult = driver.GetRunResult(); runResult.Should().NotHaveDiagnostics(); - runResult.Should().HaveProducedSourceCodeContaining("#nullable enable"); + runResult.Should().HaveProducedSourceCodeContaining(""" + #error Unhandled exception while generating controller class for type 'ExampleApi.Models.Item'. + + // Input: FullControllerInfo { CoreController = CoreControllerInfo { ResourceType = ExampleApi.Models.Item, IdType = long, ControllerNamespace = ExampleApi.Controllers, Endpoints = All, WriteNullableEnable = False }, ControllerType = ExampleApi.Controllers.ItemsController, LoggerFactoryInterface = Microsoft.Extensions.Logging.ILoggerFactory, HintFileName = ItemsController } + + // System.InvalidOperationException: Test error. + // at JsonApiDotNetCore.SourceGenerators.ControllerSourceGenerator.GenerateCode(SourceProductionContext productionContext, FullControllerInfo& fullController) in + """); } [Fact] @@ -603,11 +1165,11 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; + using ExampleApi.Models; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; - using ExampleApi.Models; + using Microsoft.Extensions.Logging; namespace Some.Path.To.Generate.Code.In; @@ -667,10 +1229,10 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; using TopLevel; namespace Controllers; @@ -730,10 +1292,10 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; public sealed partial class ItemsController : JsonApiController { @@ -791,10 +1353,10 @@ public sealed class Item : Identifiable runResult.Should().HaveProducedSourceCode(""" // - using Microsoft.Extensions.Logging; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Services; + using Microsoft.Extensions.Logging; namespace ExampleApi; @@ -842,6 +1404,16 @@ public sealed class Item : Identifiable public int Value { get; set; } } } + + namespace The.Third.One + { + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + } """) .Build(); @@ -865,9 +1437,10 @@ public sealed class Item : Identifiable runResult.Results.Should().HaveCount(1); GeneratorRunResult generatorResult = runResult.Results[0]; - generatorResult.GeneratedSources.Should().HaveCount(2); + generatorResult.GeneratedSources.Should().HaveCount(3); generatorResult.GeneratedSources[0].HintName.Should().Be("ItemsController.g.cs"); - generatorResult.GeneratedSources[1].HintName.Should().Be("ItemsController2.g.cs"); + generatorResult.GeneratedSources[1].HintName.Should().Be("ItemsController0.g.cs"); + generatorResult.GeneratedSources[2].HintName.Should().Be("ItemsController1.g.cs"); } } diff --git a/test/SourceGeneratorTests/PipelineStepCachingTests.cs b/test/SourceGeneratorTests/PipelineStepCachingTests.cs new file mode 100644 index 0000000000..e270a6fa2f --- /dev/null +++ b/test/SourceGeneratorTests/PipelineStepCachingTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using FluentAssertions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.SourceGenerators; +using Microsoft.CodeAnalysis; +using Xunit; + +namespace SourceGeneratorTests; + +public sealed class PipelineStepCachingTests +{ + private static readonly string[] AllTrackingNames = typeof(TrackingNames).GetFields() + .Where(field => field is { IsLiteral: true, IsInitOnly: false } && field.FieldType == typeof(string)) + .Select(field => (string)field.GetRawConstantValue()!).Where(value => !string.IsNullOrEmpty(value)).ToArray(); + + [Fact] + public void Pipeline_outputs_are_cached_on_generate() + { + // Arrange + var generator = new ControllerSourceGenerator(); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(IIdentifiable)) + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item : Identifiable + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + (ImmutableArray diagnostics, string[] output) = inputCompilation.AssertOutputsAreCached(generator, AllTrackingNames); + + // Assert + diagnostics.Should().BeEmpty(); + output.Should().NotBeEmpty(); + } + + [Fact] + public void Pipeline_outputs_are_cached_on_diagnostic() + { + // Arrange + var generator = new ControllerSourceGenerator(); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string source = new SourceCodeBuilder() + .WithNamespaceImportFor(typeof(ResourceAttribute)) + .InNamespace("ExampleApi.Models") + .WithCode(""" + [Resource] + public sealed class Item + { + [Attr] + public int Value { get; set; } + } + """) + .Build(); + + Compilation inputCompilation = new CompilationBuilder() + .WithDefaultReferences() + .WithSourceCode(source) + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + // Act + (ImmutableArray diagnostics, string[] output) = inputCompilation.AssertOutputsAreCached(generator, AllTrackingNames); + + // Assert + diagnostics.Should().NotBeEmpty(); + output.Should().BeEmpty(); + } +}