using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; namespace SourceGeneratedMappings; /// /// Collects every model family and every model host of the compilation into one list. /// /// /// Adding a family has to be one action, not two. A registry which somebody has to remember to add /// to is a registry which will be incomplete, and the failure it produces is the quietest one there /// is: a family which simply never answers, so its models fall into the global default and look /// merely unremarkable. /// /// Searching for the types at startup through reflection would do the same job, but this app /// publishes trimmed and uses reflection nowhere else. So the search happens while compiling, and /// what ships is a plain array. /// [Generator] #pragma warning disable RS1036 public sealed class ModelRegistryGenerator : IIncrementalGenerator #pragma warning restore RS1036 { private const string GENERATED_NAMESPACE = "AIStudio.Models.Registry"; private const string GENERATED_TYPE_NAME = "ModelRegistrations"; private const string FAMILY_BASE_TYPE = "AIStudio.Models.ModelFamily"; private const string HOST_INTERFACE = "AIStudio.Models.Hosting.IModelHost"; private static readonly DiagnosticDescriptor CANNOT_BE_REGISTERED = new( id: "MDR001", title: "A model family or host cannot be registered", messageFormat: "'{0}' is a model family or host, but the generated registry cannot create it: {1}. It will answer for no model at all.", category: "SourceGeneration", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: "The generated registry creates every family and host with its parameterless constructor. A type it cannot create is left out, which makes it silently ineffective."); public void Initialize(IncrementalGeneratorInitializationContext context) { var candidates = context.SyntaxProvider .CreateSyntaxProvider(static (node, _) => CouldBeARegistration(node), static (syntax, _) => Inspect(syntax)) .Where(static candidate => candidate.FullName is not null) .Collect(); context.RegisterSourceOutput(candidates, Generate); } /// /// Whether a syntax node is worth asking the semantic model about. /// /// /// Runs on every node of every keystroke, so it only looks at the syntax: a class with a base /// list which is neither abstract nor static. Everything else is decided once a symbol exists. /// /// The node to look at. /// True, when the node could be a family or a host. private static bool CouldBeARegistration(SyntaxNode node) => node is ClassDeclarationSyntax declaration && declaration.BaseList is { Types.Count: > 0 } && !declaration.Modifiers.Any(SyntaxKind.AbstractKeyword) && !declaration.Modifiers.Any(SyntaxKind.StaticKeyword); private static Candidate Inspect(GeneratorSyntaxContext context) { var declaration = (ClassDeclarationSyntax) context.Node; if (context.SemanticModel.GetDeclaredSymbol(declaration) is not { } symbol) return default; var isFamily = DerivesFrom(symbol, FAMILY_BASE_TYPE); var isHost = symbol.AllInterfaces.Any(candidate => candidate.ToDisplayString() == HOST_INTERFACE); if (!isFamily && !isHost) return default; return new Candidate(symbol.ToDisplayString(), isFamily, isHost, WhyItCannotBeCreated(symbol), declaration.Identifier.GetLocation()); } private static bool DerivesFrom(INamedTypeSymbol symbol, string baseTypeName) { for (var current = symbol.BaseType; current is not null; current = current.BaseType) if (current.ToDisplayString() == baseTypeName) return true; return false; } /// /// Why the generated registry could not create this type, or null when it can. /// /// The type to look at. /// A phrase which completes the diagnostic message, or null. private static string? WhyItCannotBeCreated(INamedTypeSymbol symbol) { if (symbol.IsAbstract) return "it is abstract"; if (symbol.IsGenericType) return "it is generic"; if (symbol.ContainingType is not null) return "it is nested inside another type"; if (symbol.DeclaredAccessibility is Accessibility.Private or Accessibility.Protected or Accessibility.ProtectedAndInternal) return "the registry cannot reach it from outside its own type"; var hasParameterlessConstructor = symbol.InstanceConstructors.Any(constructor => constructor.Parameters.Length == 0 && constructor.DeclaredAccessibility is Accessibility.Public or Accessibility.Internal); return hasParameterlessConstructor ? null : "it has no parameterless constructor the registry can reach"; } private static void Generate(SourceProductionContext context, ImmutableArray candidates) { var families = new List(); var hosts = new List(); foreach (var candidate in candidates) { if (candidate.FullName is null) continue; if (candidate.Problem is not null) { context.ReportDiagnostic(Diagnostic.Create(CANNOT_BE_REGISTERED, candidate.Location ?? Location.None, candidate.FullName, candidate.Problem)); continue; } if (candidate.IsFamily) families.Add(candidate.FullName); if (candidate.IsHost) hosts.Add(candidate.FullName); } // // Sorted by name and without repeats, so that the same sources produce the same file: a // partial class arrives here once per part, and the order syntax nodes are visited in is // not something to build a shipped artefact on. // var source = RenderSource(Ordered(families), Ordered(hosts)); context.AddSource("ModelFamilies.g.cs", SourceText.From(source, Encoding.UTF8)); } private static IReadOnlyList Ordered(IEnumerable typeNames) => typeNames.Distinct(StringComparer.Ordinal).OrderBy(static name => name, StringComparer.Ordinal).ToList(); private static string RenderSource(IReadOnlyList families, IReadOnlyList hosts) { var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine(); builder.Append("namespace ").Append(GENERATED_NAMESPACE).AppendLine(";"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine("/// Every model family and every model host this assembly declares."); builder.AppendLine("/// "); builder.Append("public static class ").AppendLine(GENERATED_TYPE_NAME); builder.AppendLine("{"); AppendFactory(builder, "CreateFamilies", FAMILY_BASE_TYPE, families); builder.AppendLine(); AppendFactory(builder, "CreateHosts", HOST_INTERFACE, hosts); builder.AppendLine("}"); return builder.ToString(); } private static void AppendFactory(StringBuilder builder, string methodName, string typeName, IReadOnlyList typeNames) { builder.Append(" public static global::System.Collections.Generic.IReadOnlyList ").Append(methodName).AppendLine("() =>"); builder.Append(" new global::").Append(typeName).AppendLine("[]"); builder.AppendLine(" {"); foreach (var name in typeNames) builder.Append(" new global::").Append(name).AppendLine("(),"); builder.AppendLine(" };"); } /// /// What the syntax pass found out about one type. /// /// /// A struct with value equality, because this travels through the incremental pipeline: two /// runs finding the same types have to compare as equal, or nothing downstream is ever cached. /// private readonly struct Candidate(string? fullName, bool isFamily, bool isHost, string? problem, Location? location) : IEquatable { public string? FullName { get; } = fullName; public bool IsFamily { get; } = isFamily; public bool IsHost { get; } = isHost; public string? Problem { get; } = problem; public Location? Location { get; } = location; public bool Equals(Candidate other) => this.FullName == other.FullName && this.IsFamily == other.IsFamily && this.IsHost == other.IsHost && this.Problem == other.Problem && Equals(this.Location, other.Location); public override bool Equals(object? obj) => obj is Candidate other && this.Equals(other); public override int GetHashCode() => this.FullName?.GetHashCode() ?? 0; } }