|
1 | 1 | using System.Linq; |
| 2 | +using System.Text; |
2 | 3 | using Microsoft.CodeAnalysis; |
3 | 4 | using Microsoft.CodeAnalysis.CSharp.Syntax; |
4 | | -using static Devlooped.CloudActors.AnalysisExtensions; |
5 | 5 |
|
6 | 6 | namespace Devlooped.CloudActors; |
7 | 7 |
|
| 8 | +/// <summary> |
| 9 | +/// Generates strongly-typed, message-specific <see cref="IActorBus"/> extension methods for each actor. |
| 10 | +/// For actors with a typed ID, emits (TypedId, ConcreteMessage) overloads. |
| 11 | +/// For actors with a string ID, emits (string, ConcreteMessage) overloads. |
| 12 | +/// Only messages actually handled by the actor (declared handler methods) get overloads. |
| 13 | +/// </summary> |
8 | 14 | [Generator(LanguageNames.CSharp)] |
9 | 15 | class ActorBusOverloadGenerator : IIncrementalGenerator |
10 | 16 | { |
11 | 17 | public void Initialize(IncrementalGeneratorInitializationContext context) |
12 | 18 | { |
13 | | - var interfaces = context.CompilationProvider |
14 | | - .Select((c, _) => ( |
15 | | - VoidCommand: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand")?.ToDisplayString(FullName), |
16 | | - Command: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand`1")?.ToDisplayString(FullName), |
17 | | - Query: c.GetTypeByMetadataName("Devlooped.CloudActors.IActorQuery`1")?.ToDisplayString(FullName))) |
18 | | - .WithTrackingName(TrackingNames.BusInterfaces); |
19 | | - |
20 | | - var messages = context.SyntaxProvider.CreateSyntaxProvider( |
| 19 | + var actors = context.SyntaxProvider.CreateSyntaxProvider( |
21 | 20 | predicate: static (node, _) => |
22 | | - node is TypeDeclarationSyntax tds && |
23 | | - tds.BaseList?.Types.Count > 0, |
| 21 | + node is ClassDeclarationSyntax cds && |
| 22 | + cds.AttributeLists.Count > 0, |
24 | 23 | transform: static (ctx, ct) => |
25 | 24 | { |
26 | | - if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol type) |
27 | | - return default; |
| 25 | + if (ctx.SemanticModel.GetDeclaredSymbol(ctx.Node, ct) is not INamedTypeSymbol symbol) |
| 26 | + return (ActorModel?)null; |
28 | 27 |
|
29 | | - if (!type.IsActorMessage()) |
30 | | - return default; |
| 28 | + if (!symbol.IsActor()) |
| 29 | + return null; |
31 | 30 |
|
32 | | - var voidCommand = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand"); |
33 | | - var command = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorCommand`1"); |
34 | | - var query = ctx.SemanticModel.Compilation.GetTypeByMetadataName("Devlooped.CloudActors.IActorQuery`1"); |
| 31 | + var iParsable = ctx.SemanticModel.Compilation.GetTypeByMetadataName("System.IParsable`1"); |
| 32 | + var guidType = ctx.SemanticModel.Compilation.GetTypeByMetadataName("System.Guid"); |
| 33 | + var hasCreateVersion7 = guidType?.GetMembers("CreateVersion7") |
| 34 | + .OfType<IMethodSymbol>() |
| 35 | + .Any(m => m.IsStatic && m.Parameters.Length == 0) == true; |
35 | 36 |
|
36 | | - return ModelExtractors.ExtractActorMessageModel(type, voidCommand, command, query); |
| 37 | + return ModelExtractors.ExtractActorModel(symbol, iParsable, hasCreateVersion7); |
37 | 38 | }) |
38 | 39 | .Where(static x => x != null) |
39 | 40 | .Select(static (x, _) => x!.Value) |
40 | 41 | .WithTrackingName(TrackingNames.BusOverloads); |
41 | 42 |
|
42 | | - context.RegisterSourceOutput(messages, (ctx, model) => |
| 43 | + // Emit the base partial class only when at least one actor has emittable overloads. |
| 44 | + // Using Collect+Select(bool) keeps this a single cached bool — not per-actor — so the |
| 45 | + // base file is only added/removed when the set of emittable actors crosses zero. |
| 46 | + var hasOverloads = actors |
| 47 | + .Where(static a => |
| 48 | + (a.VoidCommands.Length > 0 || a.Commands.Length > 0 || a.Queries.Length > 0) && |
| 49 | + (a.IsPrimitiveId || a.IsTypedId)) |
| 50 | + .Collect() |
| 51 | + .Select(static (arr, _) => arr.Length > 0); |
| 52 | + |
| 53 | + context.RegisterSourceOutput(hasOverloads, static (ctx, hasAny) => |
| 54 | + { |
| 55 | + if (!hasAny) |
| 56 | + return; |
| 57 | + |
| 58 | + ctx.AddSource("ActorBusExtensions.g.cs", |
| 59 | + """ |
| 60 | + // <auto-generated/> |
| 61 | + using System.ComponentModel; |
| 62 | + [EditorBrowsable(EditorBrowsableState.Never)] |
| 63 | + public static partial class ActorBusExtensions { } |
| 64 | + """); |
| 65 | + }); |
| 66 | + |
| 67 | + // Per-actor overloads remain incremental: each actor's file is independently cached. |
| 68 | + context.RegisterSourceOutput(actors, (ctx, actor) => |
43 | 69 | { |
44 | | - var file = $"{model.FileName}.g.cs"; |
| 70 | + if (actor.VoidCommands.Length == 0 && actor.Commands.Length == 0 && actor.Queries.Length == 0) |
| 71 | + return; |
| 72 | + |
| 73 | + var grainType = actor.Name.ToLowerInvariant(); |
45 | 74 |
|
46 | | - switch (model.Kind) |
| 75 | + string busIdTypeName; |
| 76 | + string idExpr; |
| 77 | + |
| 78 | + if (actor.IsPrimitiveId) |
| 79 | + { |
| 80 | + busIdTypeName = $"{actor.FullName}.{actor.Name}Id"; |
| 81 | + idExpr = $"$\"{grainType}/{{id.Id}}\""; |
| 82 | + } |
| 83 | + else if (actor.IsTypedId && actor.IdTypeFullName != null) |
47 | 84 | { |
48 | | - case ActorMessageKind.VoidCommand: |
49 | | - ctx.AddSource(file, |
50 | | - $$""" |
51 | | - using System.Threading.Tasks; |
52 | | - using Devlooped.CloudActors; |
53 | | - |
54 | | - static partial class ActorBusExtensions |
55 | | - { |
56 | | - public static Task ExecuteAsync(this IActorBus bus, string id, {{model.FullName}} command) |
57 | | - => bus.ExecuteAsync(id, (IActorCommand)command); |
58 | | - } |
59 | | - """); |
60 | | - break; |
61 | | - |
62 | | - case ActorMessageKind.Command: |
63 | | - ctx.AddSource(file, |
64 | | - $$""" |
65 | | - using System.Threading.Tasks; |
66 | | - using Devlooped.CloudActors; |
67 | | -
|
68 | | - static partial class ActorBusExtensions |
69 | | - { |
70 | | - public static Task<{{model.ReturnTypeFullName}}> ExecuteAsync(this IActorBus bus, string id, {{model.FullName}} command) |
71 | | - => bus.ExecuteAsync<{{model.ReturnTypeFullName}}>(id, command); |
72 | | - } |
73 | | - """); |
74 | | - break; |
75 | | - |
76 | | - case ActorMessageKind.Query: |
77 | | - ctx.AddSource(file, |
78 | | - $$""" |
79 | | - using System.Threading.Tasks; |
80 | | - using Devlooped.CloudActors; |
81 | | -
|
82 | | - static partial class ActorBusExtensions |
83 | | - { |
84 | | - public static Task<{{model.ReturnTypeFullName}}> QueryAsync(this IActorBus bus, string id, {{model.FullName}} query) |
85 | | - => bus.QueryAsync<{{model.ReturnTypeFullName}}>(id, query); |
86 | | - } |
87 | | - """); |
88 | | - break; |
| 85 | + busIdTypeName = actor.IdTypeFullName; |
| 86 | + idExpr = $"$\"{grainType}/{{id}}\""; |
89 | 87 | } |
| 88 | + else |
| 89 | + { |
| 90 | + // Actor with no recognized ID type — no typed overloads can be generated. |
| 91 | + return; |
| 92 | + } |
| 93 | + |
| 94 | + var sb = new StringBuilder(); |
| 95 | + sb.AppendLine("// <auto-generated/>"); |
| 96 | + sb.AppendLine("#nullable enable"); |
| 97 | + sb.AppendLine("using System.Runtime.CompilerServices;"); |
| 98 | + sb.AppendLine("using System.Threading.Tasks;"); |
| 99 | + sb.AppendLine("using Devlooped.CloudActors;"); |
| 100 | + sb.AppendLine(); |
| 101 | + sb.AppendLine("static partial class ActorBusExtensions"); |
| 102 | + sb.AppendLine("{"); |
| 103 | + |
| 104 | + foreach (var cmd in actor.VoidCommands.AsImmutableArray()) |
| 105 | + { |
| 106 | + sb.AppendLine( |
| 107 | + $" /// <summary>Invokes a state-changing command on a <see cref=\"{actor.FullName}\"/> actor.</summary>"); |
| 108 | + sb.AppendLine( |
| 109 | + $" public static Task ExecuteAsync(this IActorBus bus, {busIdTypeName} id, {cmd.Type} command,"); |
| 110 | + sb.AppendLine( |
| 111 | + $" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)"); |
| 112 | + sb.AppendLine( |
| 113 | + $" => bus.ExecuteAsync({idExpr}, (IActorCommand)command, callerName, callerFile, callerLine);"); |
| 114 | + sb.AppendLine(); |
| 115 | + } |
| 116 | + |
| 117 | + foreach (var cmd in actor.Commands.AsImmutableArray()) |
| 118 | + { |
| 119 | + var returnType = cmd.ReturnTypeFullName!; |
| 120 | + sb.AppendLine( |
| 121 | + $" /// <summary>Invokes a state-changing command on a <see cref=\"{actor.FullName}\"/> actor.</summary>"); |
| 122 | + sb.AppendLine( |
| 123 | + $" public static Task<{returnType}> ExecuteAsync(this IActorBus bus, {busIdTypeName} id, {cmd.Type} command,"); |
| 124 | + sb.AppendLine( |
| 125 | + $" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)"); |
| 126 | + sb.AppendLine( |
| 127 | + $" => bus.ExecuteAsync<{returnType}>({idExpr}, command, callerName, callerFile, callerLine);"); |
| 128 | + sb.AppendLine(); |
| 129 | + } |
| 130 | + |
| 131 | + foreach (var query in actor.Queries.AsImmutableArray()) |
| 132 | + { |
| 133 | + var returnType = query.ReturnTypeFullName!; |
| 134 | + sb.AppendLine( |
| 135 | + $" /// <summary>Invokes a read-only query on a <see cref=\"{actor.FullName}\"/> actor.</summary>"); |
| 136 | + sb.AppendLine( |
| 137 | + $" public static Task<{returnType}> QueryAsync(this IActorBus bus, {busIdTypeName} id, {query.Type} query,"); |
| 138 | + sb.AppendLine( |
| 139 | + $" [CallerMemberName] string? callerName = default, [CallerFilePath] string? callerFile = default, [CallerLineNumber] int? callerLine = default)"); |
| 140 | + sb.AppendLine( |
| 141 | + $" => bus.QueryAsync<{returnType}>({idExpr}, query, callerName, callerFile, callerLine);"); |
| 142 | + sb.AppendLine(); |
| 143 | + } |
| 144 | + |
| 145 | + sb.AppendLine("}"); |
| 146 | + |
| 147 | + ctx.AddSource($"{actor.FileName}.Bus.g.cs", sb.ToString()); |
90 | 148 | }); |
91 | 149 | } |
92 | 150 | } |
0 commit comments