File: CodeGeneration\CodeGeneratorResolver.cs
Web Access
Project: src\src\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj (Aspire.Hosting.RemoteHost)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Reflection;
using Aspire.TypeSystem;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.RemoteHost.CodeGeneration;
 
/// <summary>
/// Resolves code generators by language, discovering them from loaded assemblies.
/// </summary>
internal sealed class CodeGeneratorResolver
{
    private readonly Lazy<Dictionary<string, ICodeGenerator>> _generators;
    private readonly Lazy<Dictionary<string, IApiReferenceExporter>> _exporters;
    private readonly ILogger<CodeGeneratorResolver> _logger;
 
    public CodeGeneratorResolver(
        IServiceProvider serviceProvider,
        AssemblyLoader assemblyLoader,
        ILogger<CodeGeneratorResolver> logger)
        : this(serviceProvider, assemblyLoader.GetAssemblies, logger)
    {
    }
 
    // Test-only seam: lets unit tests inject a synthetic assembly set without going
    // through the AssemblyLoader (which is sealed and probes the file system).
    internal CodeGeneratorResolver(
        IServiceProvider serviceProvider,
        Func<IReadOnlyList<Assembly>> assembliesProvider,
        ILogger<CodeGeneratorResolver> logger)
    {
        _logger = logger;
        _generators = new Lazy<Dictionary<string, ICodeGenerator>>(
            () => DiscoverGenerators(serviceProvider, assembliesProvider()));
        _exporters = new Lazy<Dictionary<string, IApiReferenceExporter>>(
            () => DiscoverExporters(serviceProvider, assembliesProvider()));
    }
 
    /// <summary>
    /// Gets a code generator for the specified language.
    /// </summary>
    /// <param name="language">The target language (e.g., "TypeScript", "Python").</param>
    /// <returns>The code generator, or null if not found.</returns>
    public ICodeGenerator? GetCodeGenerator(string language)
    {
        _generators.Value.TryGetValue(language, out var generator);
        return generator;
    }
 
    /// <summary>
    /// Gets the API reference exporter for the specified language, if the language supports API export.
    /// </summary>
    /// <param name="language">The target language (e.g., "TypeScript", "Python").</param>
    /// <returns>
    /// The exporter, or <see langword="null"/> when no generator is registered for the language or
    /// the language provides no <see cref="IApiReferenceExporter"/>.
    /// </returns>
    /// <remarks>
    /// <para>
    /// An exporter is never reachable for a language whose code generator is not: a documented API
    /// that no generator produces would be worse than no documentation at all. That is why the
    /// generator lookup gates the result even though exporters are discovered independently.
    /// </para>
    /// <para>
    /// Exporters are discovered as their own types rather than read off the generator so that a
    /// language provider can add export support without changing the generator type's interface
    /// list. <c>Aspire.TypeSystem</c> is force-shared from the default load context, so a generator
    /// implementing a newly added shared interface fails to load entirely under a CLI that predates
    /// it (see <c>AtsTypeScriptApiReferenceExporter</c>). A generator that implements the interface
    /// itself is still honored, so a provider that keeps both roles on one type keeps working.
    /// </para>
    /// </remarks>
    public IApiReferenceExporter? GetApiReferenceExporter(string language)
    {
        if (GetCodeGenerator(language) is not { } generator)
        {
            return null;
        }
 
        if (generator is IApiReferenceExporter selfExporter)
        {
            return selfExporter;
        }
 
        _exporters.Value.TryGetValue(language, out var exporter);
        return exporter;
    }
 
    /// <summary>
    /// Gets the languages of all discovered code generators.
    /// </summary>
    /// <returns>The set of supported language identifiers.</returns>
    public IReadOnlyCollection<string> GetSupportedLanguages()
    {
        return _generators.Value.Keys.ToArray();
    }
 
    private Dictionary<string, ICodeGenerator> DiscoverGenerators(
        IServiceProvider serviceProvider,
        IReadOnlyList<Assembly> assemblies)
    {
        var generators = new Dictionary<string, ICodeGenerator>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var assembly in assemblies)
        {
            var assemblyName = assembly.GetName().Name;
            var types = GetLoadableTypes(assembly, assemblyName, out var hadTypeLoadFailure);
 
            var discoveredInAssembly = 0;
            foreach (var type in types)
            {
                if (!type.IsAbstract &&
                    !type.IsInterface &&
                    typeof(ICodeGenerator).IsAssignableFrom(type))
                {
                    try
                    {
                        var generator = (ICodeGenerator)ActivatorUtilities.CreateInstance(serviceProvider, type);
                        generators[generator.Language] = generator;
                        discoveredInAssembly++;
                        _logger.LogDebug("Discovered code generator: {TypeName} for language '{Language}'", type.Name, generator.Language);
                    }
                    catch (Exception ex)
                    {
                        _logger.LogWarning(ex, "Failed to instantiate code generator '{TypeName}'", type.Name);
                    }
                }
            }
 
            // If an assembly named like a code-generation contributor produced zero generators,
            // that is almost certainly a silent type-load failure rather than an intentional
            // design. Log a Warning so the user can see it.
            if (discoveredInAssembly == 0 && LooksLikeCodeGeneratorAssembly(assemblyName))
            {
                _logger.LogWarning(
                    "Assembly '{AssemblyName}' was loaded but did not contribute any {Interface} implementations. {Hint}",
                    assemblyName,
                    nameof(ICodeGenerator),
                    hadTypeLoadFailure
                        ? "This is likely caused by a binary mismatch between the bundled and probed assemblies (see preceding LoaderExceptions)."
                        : "Verify the assembly contains a non-abstract type that implements " + typeof(ICodeGenerator).FullName + ".");
            }
        }
 
        return generators;
    }
 
    private Dictionary<string, IApiReferenceExporter> DiscoverExporters(
        IServiceProvider serviceProvider,
        IReadOnlyList<Assembly> assemblies)
    {
        var exporters = new Dictionary<string, IApiReferenceExporter>(StringComparer.OrdinalIgnoreCase);
 
        foreach (var assembly in assemblies)
        {
            var assemblyName = assembly.GetName().Name;
 
            // An assembly with no exporter is the normal case (most languages generate code they
            // cannot yet describe), so unlike generator discovery this pass never warns about
            // finding nothing. A type-load failure was already reported by DiscoverGenerators.
            foreach (var type in GetLoadableTypes(assembly, assemblyName, out _))
            {
                if (type.IsAbstract || type.IsInterface || !typeof(IApiReferenceExporter).IsAssignableFrom(type))
                {
                    continue;
                }
 
                try
                {
                    var exporter = (IApiReferenceExporter)ActivatorUtilities.CreateInstance(serviceProvider, type);
                    exporters[exporter.Language] = exporter;
                    _logger.LogDebug("Discovered API reference exporter: {TypeName} for language '{Language}'", type.Name, exporter.Language);
                }
                catch (Exception ex)
                {
                    _logger.LogWarning(ex, "Failed to instantiate API reference exporter '{TypeName}'", type.Name);
                }
            }
        }
 
        return exporters;
    }
 
    /// <summary>
    /// Returns the types an assembly can actually load, keeping the ones that bound when others did
    /// not. Dropping the whole assembly on a single unloadable type would take every generator in it
    /// down with that type.
    /// </summary>
    private Type[] GetLoadableTypes(Assembly assembly, string? assemblyName, out bool hadTypeLoadFailure)
    {
        hadTypeLoadFailure = false;
 
        try
        {
            return assembly.GetTypes();
        }
        catch (ReflectionTypeLoadException ex)
        {
            hadTypeLoadFailure = true;
            // Surface loader binding failures at Warning level. These typically indicate
            // a binary mismatch between the bundled runtime assemblies and the integration
            // assemblies loaded from disk (for example, when Aspire.TypeSystem versions
            // diverge). Including the LoaderExceptions in the log is essential for
            // diagnosing these failures, which previously disappeared into Debug-level
            // output that the apphost server never wrote to disk.
            var loaderMessages = ex.LoaderExceptions is { Length: > 0 } loaders
                ? string.Join("; ", loaders.Where(e => e is not null).Select(e => e!.Message).Distinct())
                : "(no LoaderExceptions captured)";
            _logger.LogWarning(
                ex,
                "Some types in assembly '{AssemblyName}' could not be loaded; {LoadedCount} of {TotalCount} types are available. LoaderExceptions: {LoaderExceptions}",
                assemblyName,
                ex.Types.Count(t => t is not null),
                ex.Types.Length,
                loaderMessages);
            return ex.Types.Where(t => t is not null).ToArray()!;
        }
    }
 
    private static bool LooksLikeCodeGeneratorAssembly(string? assemblyName)
        => assemblyName is not null
           && assemblyName.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.OrdinalIgnoreCase);
}