Search code examples
c#.netasp.net-coresourcegeneratorscsharp-source-generator

Source Generator won't see dependent project's classes


I have a Modular Monolith along with Clean Architecture as follows: enter image description here

Application projects depends on Domain projects, and endpoints are in Application projects while Entities are in Domain projects. Delete endpoints are almost identical, so I wanted to create delete endpoints with source generation. Here is the problem:

Entity definition/abstraction is in Common.Domain project:

public abstract class AuditableEntity : IAuditableEntity
{
....
}

And all modules' Domain projects are depends on this Common.Domain and all Entities are derived from Common.Domain.Entities.AuditableEntity. An example entity inside Inventory module is:

public class Product : AggregateRoot<ProductId> // AggregateRoot is derived from AuditableEntity so Product is an AuditableEntity
{
....
}

So my DeleteEndpointSourceGenerator is able to find entities derived from AuditableEntity if it runs on Domain projects, but can not find any of them if it runs on Application projects(as I need it). How can I solve this?

Here is the DeleteEndpointSourceGenerator:

[Generator]
public class DeleteEndpointSourceGenerator : ISourceGenerator
{
    private const string AuditableEntityFullName = "Common.Domain.Entities.AuditableEntity";

    public void Initialize(GeneratorInitializationContext context)
    {
        context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
    }

    public void Execute(GeneratorExecutionContext context)
    {
        if (context.SyntaxReceiver is not SyntaxReceiver receiver)
        {
            context.ReportDiagnostic(Diagnostic.Create(_noSyntaxReceiverDescriptor, Location.None));
            return;
        }

        var compilation = context.Compilation;

        var auditableEntitySymbol = compilation.GetTypeByMetadataName(AuditableEntityFullName);
        if (auditableEntitySymbol == null)
        {
            context.ReportDiagnostic(Diagnostic.Create(_auditableEntityNotFoundDescriptor, Location.None));
            return;
        }

        foreach (var classDeclaration in receiver.CandidateClasses)
        {
            var model = compilation.GetSemanticModel(classDeclaration.SyntaxTree);

            if (model.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol classSymbol)
            {
                continue;
            }

            if (!IsDerivedFrom(classSymbol, auditableEntitySymbol))
            {
                continue;
            }

            context.ReportDiagnostic(Diagnostic.Create(_classInheritsDescriptor, Location.None, classSymbol.Name, auditableEntitySymbol.Name));

            var namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
            var className = classSymbol.Name;

            var source = GenerateDeleteEndpointCode(namespaceName, className);
            context.AddSource($"{className}_DeleteEndpoint.g.cs", SourceText.From(source, Encoding.UTF8));
            context.ReportDiagnostic(Diagnostic.Create(_endpointGeneratedDescriptor, Location.None, className));
        }
    }

    private static bool IsDerivedFrom(INamedTypeSymbol? classSymbol, INamedTypeSymbol baseTypeSymbol)
    {
        var currentType = classSymbol;
        while (currentType != null)
        {
            if (SymbolEqualityComparer.Default.Equals(currentType, baseTypeSymbol))
            {
                return true;
            }
            currentType = currentType.BaseType;
        }
        return false;
    }

    private static string GenerateDeleteEndpointCode(string namespaceName, string className)
    {
        var pluralClassName = Pluralize(className);
        return $@"
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Common.Domain.ResultMonad;
using Common.Application.Auth;
using Common.Application.Extensions;
using Common.Application.Persistence;
using {namespaceName};
using Microsoft.Extensions.DependencyInjection;
using Common.Application.ModelBinders;
using Ardalis.Specification;

namespace SourceGenerated.Application.{pluralClassName}.v1.Delete;

internal static class {className}DeleteEndpoint
{{
    internal static void MapEndpoint(RouteGroupBuilder apiGroup)
    {{
        apiGroup
            .MapDelete(""{{id}}"", Delete{className}Async)
            .WithDescription(""Delete a {className}."")
            .MustHavePermission(CustomActions.Delete, CustomResources.{pluralClassName})
            .Produces(StatusCodes.Status204NoContent)
            .TransformResultToNoContentResponse();
    }}

    private sealed class {className}ByIdSpec : SingleResultSpecification<{className}>
    {{
        public {className}ByIdSpec({className}Id id)
            => Query
                .Where(p => p.Id == id);
    }}

    private static async Task<Result> Delete{className}Async(
        [FromRoute, ModelBinder(typeof(StronglyTypedIdBinder<{className}Id>))] {className}Id id,
        [FromServices] IRepository<{className}> repository,
        [FromKeyedServices(nameof(Inventory))] IUnitOfWork unitOfWork,
        CancellationToken cancellationToken)
        => await repository
            .SingleOrDefaultAsResultAsync(new {className}ByIdSpec(id), cancellationToken)
            .TapAsync(entity => repository.Delete(entity))
            .TapAsync(async _ => await unitOfWork.SaveChangesAsync(cancellationToken));
}}
";
    }

    private static string Pluralize(string word)
    {
        if (word.EndsWith("y", StringComparison.OrdinalIgnoreCase))
        {
            return $"{word.Substring(0, word.Length - 1)}ies";
        }

        if (word.EndsWith("s", StringComparison.OrdinalIgnoreCase))
        {
            return $"{word}es";
        }

        return $"{word}s";
    }

    private sealed class SyntaxReceiver : ISyntaxReceiver
    {
        public List<ClassDeclarationSyntax> CandidateClasses { get; } = [];

        public void OnVisitSyntaxNode(SyntaxNode syntaxNode)
        {
            if (syntaxNode is ClassDeclarationSyntax classDeclarationSyntax)
            {
                CandidateClasses.Add(classDeclarationSyntax);
            }
        }
    }

    private static readonly DiagnosticDescriptor _noSyntaxReceiverDescriptor = new(
        id: "GEN001",
        title: "Syntax Receiver Not Found",
        messageFormat: "Syntax receiver not found",
        category: "SourceGenerator",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    private static readonly DiagnosticDescriptor _auditableEntityNotFoundDescriptor = new(
        id: "GEN002",
        title: "AuditableEntity Not Found",
        messageFormat: "AuditableEntity not found",
        category: "SourceGenerator",
        defaultSeverity: DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    private static readonly DiagnosticDescriptor _endpointGeneratedDescriptor = new(
        id: "GEN003",
        title: "Endpoint Generated",
        messageFormat: "Generated delete endpoint for {0}",
        category: "SourceGenerator",
        defaultSeverity: DiagnosticSeverity.Info,
        isEnabledByDefault: true);

    private static readonly DiagnosticDescriptor _classInheritsDescriptor = new(
        id: "GEN004",
        title: "Class Inherits AuditableEntity",
        messageFormat: "{0} is derived from {1}",
        category: "SourceGenerator",
        defaultSeverity: DiagnosticSeverity.Info,
        isEnabledByDefault: true);
}

Expected behaviour is:

  • Let's say we run this source generator on Inventory.Application project, it should find the Inventory.Domain entities (Inventory.Application has a reference to Inventory.Domain) then generate this endpoint. But it can't. It only finds Inventory.Domain entities if it runs on Inventory.Domain.

Solution

  • While running the generator see sources belonging to assembly that is being built. So it's normal that it cannot see anything outside.

    To inspect other, referenced assembly use the collection of referenced assembly symbols accessible by:

    IAssemblySymbol assemblySymbol = context.Compilation
        .SourceModule
        .ReferencedAssemblySymbols
        .First(q => q.Name == "Inventory.Domain");
    

    There you will find AuditableEntities from "Inventory.Domain".

    You can iterate over types using the following loop:

    INamespaceSymbol rootNs = assemblySymbol.GlobalNamespace;
    var stack = new Stack<INamespaceSymbol>();
    stack.Push(rootNs);
    while (stack.Count > 0)
    {
        foreach (var member in stack.Pop().GetMembers())
        {
            if (member is INamespaceSymbol namespaceSymbol)
            {
                stack.Push(namespaceSymbol);
            }
            else if (member is INamedTypeSymbol namedTypeSymbol)
            {
                CheckIfItsAuditableEntityAndProcess...(context, namedTypeSymbol);
            }
        }
    }