From 618cbcee9f55620c313d9e8564989ce22bef3668 Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 9 Oct 2026 10:43:19 -0500 Subject: [PATCH] GH-990: source generator infers a handler's emitted events into an assembly manifest - New JasperFx.Events.ICarriesEvents marker and EmittedEventsAttribute (assembly, AllowMultiple). - New generator pipeline: a method that takes an IEventStream or an IRefersToAggregate parameter, or returns an ICarriesEvents type (incl. tuple / Task), has its body read for AppendOne/AppendMany, ICarriesEvents factory/ctor args, collection initializers and Add calls. Only object-typed parameter slots count as events; object-typed values are skipped. Per-method results are value-equatable, so unrelated edits stay cached. - Pass 2's syntax filter no longer requires "Aggregate" in the attribute name; the IRefersToAggregate check decides, so [WriteModel]/[ReadModel]/[DcbModel] are analyzed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01J8KxC18Q51z5w4u6YARwe4 --- docs/codegen/projection-apply-dispatch.md | 31 ++ .../EmittedEventsManifestTests.cs | 357 ++++++++++++++ .../AggregateEvolverGenerator.cs | 45 +- .../EmittedEventsAnalyzer.cs | 441 ++++++++++++++++++ src/JasperFx.Events/EmittedEventsAttribute.cs | 40 ++ src/JasperFx.Events/ICarriesEvents.cs | 14 + 6 files changed, 916 insertions(+), 12 deletions(-) create mode 100644 src/JasperFx.Events.SourceGenerator.Tests/EmittedEventsManifestTests.cs create mode 100644 src/JasperFx.Events.SourceGenerator/EmittedEventsAnalyzer.cs create mode 100644 src/JasperFx.Events/EmittedEventsAttribute.cs create mode 100644 src/JasperFx.Events/ICarriesEvents.cs diff --git a/docs/codegen/projection-apply-dispatch.md b/docs/codegen/projection-apply-dispatch.md index bef51be5..599c0f40 100644 --- a/docs/codegen/projection-apply-dispatch.md +++ b/docs/codegen/projection-apply-dispatch.md @@ -261,3 +261,34 @@ attached to that project. It is opt-in because nothing in MSBuild can tell a pro suppressed" is not by itself evidence of a mistake: Marten's own build suppresses the analyzer package's assets deliberately, bundling the dll instead. That is the finding that argued #894 out of being a default warning. + +## Inferring a handler's emitted events (#990) + +Since [#990](https://github.com/JasperFx/jasperfx/issues/990) the generator also records which +events each handler method appends, read from its body, as an assembly-level manifest: + +```csharp +[assembly: JasperFx.Events.EmittedEvents(typeof(AcceptHomeCheckAssignmentHandler), "Handle", + typeof(HomeCheckAssignmentAccepted))] +``` + +It keys off JasperFx.Events types only, so the generator needs nothing from Wolverine: + +- A method is a candidate when a parameter is an `IEventStream`, a parameter carries an + `IRefersToAggregate` attribute, or the return type (or a tuple element, through + `Task` / `ValueTask`) implements the `ICarriesEvents` marker. +- Events are read from `AppendOne` / `AppendMany` on an `IEventStream`, from the arguments + of a constructor or factory returning an `ICarriesEvents` type, and from `Add` (or a + collection initializer) on one. Only `object`-typed parameter slots count as events, so a + `Guid` or `string` stream id beside them is never mistaken for one. +- An event whose static type is `object` can't be inferred and is skipped silently. The + manifest is a lower bound, and an explicit `[Emits]` remains the override. + +Reading the manifest belongs to the consumer (Wolverine). Like the marker above, it is emitted +only when the compilation can see `EmittedEventsAttribute`, and the attribute allows +multiple applications for the double-load case. + +The same issue fixed pass 2's syntax filter, which let a method through only when a parameter +attribute had "Aggregate" in its name. Wolverine's `[WriteModel]` / `[ReadModel]` / +`[DcbModel]` implement `IRefersToAggregate` but never reached the semantic check. The filter +now admits any attributed parameter, and the marker check decides. diff --git a/src/JasperFx.Events.SourceGenerator.Tests/EmittedEventsManifestTests.cs b/src/JasperFx.Events.SourceGenerator.Tests/EmittedEventsManifestTests.cs new file mode 100644 index 00000000..2961a6d7 --- /dev/null +++ b/src/JasperFx.Events.SourceGenerator.Tests/EmittedEventsManifestTests.cs @@ -0,0 +1,357 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Shouldly; + +namespace JasperFx.Events.SourceGenerator.Tests; + +/// +/// jasperfx#990 — the generator infers the events a handler appends from its body, keyed off +/// JasperFx.Events marker types (IEventStream<T>, IRefersToAggregate, +/// ICarriesEvents), and records them as [assembly: EmittedEvents(...)] so Wolverine can +/// stop scaffolding [Emits]. +/// +public class EmittedEventsManifestTests +{ + private const string ManifestFileName = "JasperFxEmittedEvents.g.cs"; + + /// + /// Stand-ins for Wolverine's store-agnostic attribute and event-carrying return types. They are + /// defined here, not referenced, because the point is that the generator needs nothing from + /// Wolverine: only the JasperFx.Events markers they implement. + /// + private const string Preamble = @" +using System; +using System.Collections.Generic; +using JasperFx.Events; +using JasperFx.Events.Aggregation; + +namespace App; + +public class WriteModelAttribute : Attribute, IRefersToAggregate; + +public class Events : List, ICarriesEvents; + +public class StartStream : ICarriesEvents +{ + public StartStream(Guid streamId, params object[] events) { } +} + +public static class Storage +{ + public static StartStream StartStream(Guid id, params object[] events) => new(id, events); +} + +public class Appointment { public Guid Id { get; set; } } +public class Patient { public Guid Id { get; set; } } + +public record Booked; +public record Confirmed; +public record Cancelled; +public record PatientNotified; +"; + + private static string Manifest(string body) => + GeneratorHarness.GeneratedSource(Preamble + body, ManifestFileName); + + private static string[] FileNames(string body) => GeneratorHarness.GeneratedFileNames(Preamble + body); + + private static void ShouldCompile(string body) => + GeneratorHarness.GeneratedCodeErrors(Preamble + body).ShouldBeEmpty(); + + [Fact] + public void append_one_on_an_event_stream() + { + const string body = @" +public static class ConfirmHandler +{ + public static void Handle(string command, IEventStream stream) + { + stream.AppendOne(new Confirmed()); + } +} +"; + Manifest(body).ShouldContain( + "[assembly: global::JasperFx.Events.EmittedEvents(typeof(global::App.ConfirmHandler), \"Handle\", typeof(global::App.Confirmed))]"); + ShouldCompile(body); + } + + [Fact] + public void append_many_with_several_events() + { + const string body = @" +public static class BookHandler +{ + public static void Handle(string command, IEventStream stream) + { + var confirmed = new Confirmed(); + stream.AppendMany(new Booked(), confirmed); + stream.AppendMany(new object[] { new Cancelled() }); + } +} +"; + Manifest(body).ShouldContain( + "typeof(global::App.BookHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Confirmed), typeof(global::App.Cancelled))]"); + ShouldCompile(body); + } + + [Fact] + public void multi_stream_with_two_event_stream_parameters() + { + const string body = @" +public static class TransferHandler +{ + public static void Handle(string command, IEventStream appointment, IEventStream patient) + { + appointment.AppendOne(new Cancelled()); + patient.AppendOne(new PatientNotified()); + } +} +"; + Manifest(body).ShouldContain( + "typeof(global::App.TransferHandler), \"Handle\", typeof(global::App.Cancelled), typeof(global::App.PatientNotified))]"); + ShouldCompile(body); + } + + [Fact] + public void a_factory_returning_an_event_carrier() + { + const string body = @" +public static class StartHandler +{ + public static StartStream Handle(string command) + { + return Storage.StartStream(Guid.NewGuid(), new Booked(), new Confirmed()); + } +} + +public static class StartByConstructorHandler +{ + public static StartStream Handle(string command) => new StartStream(Guid.NewGuid(), new Booked()); +} +"; + var manifest = Manifest(body); + + // The Guid stream id is not an event: only object-typed slots are read. + manifest.ShouldContain( + "typeof(global::App.StartHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Confirmed))]"); + manifest.ShouldContain( + "typeof(global::App.StartByConstructorHandler), \"Handle\", typeof(global::App.Booked))]"); + manifest.ShouldNotContain("typeof(global::System.Guid)"); + ShouldCompile(body); + } + + [Fact] + public void a_collection_initializer() + { + const string body = @" +public static class InitializerHandler +{ + public static Events Handle(string command, [WriteModel] Appointment appointment) + { + return new Events { new Confirmed(), new PatientNotified() }; + } +} +"; + Manifest(body).ShouldContain( + "typeof(global::App.InitializerHandler), \"Handle\", typeof(global::App.Confirmed), typeof(global::App.PatientNotified))]"); + ShouldCompile(body); + } + + [Fact] + public void add_on_a_local() + { + const string body = @" +public static class AddHandler +{ + public static Events Handle(string command, bool cancel) + { + var events = new Events(); + events.Add(new Booked()); + if (cancel) events.Add(new Cancelled()); + return events; + } +} +"; + Manifest(body).ShouldContain( + "typeof(global::App.AddHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Cancelled))]"); + ShouldCompile(body); + } + + [Fact] + public void a_tuple_return() + { + const string body = @" +public static class TupleHandler +{ + public static System.Threading.Tasks.Task<(Events, string)> Handle(string command) + { + var events = new Events { new Booked() }; + return System.Threading.Tasks.Task.FromResult((events, ""done"")); + } +} +"; + Manifest(body).ShouldContain( + "typeof(global::App.TupleHandler), \"Handle\", typeof(global::App.Booked))]"); + ShouldCompile(body); + } + + [Fact] + public void an_object_typed_event_is_skipped() + { + const string body = @" +public static class ObjectHandler +{ + public static void Handle(string command, IEventStream stream) + { + object unknown = new Booked(); + stream.AppendOne(unknown); + stream.AppendOne(new Confirmed()); + } +} +"; + var manifest = Manifest(body); + + manifest.ShouldContain("typeof(global::App.ObjectHandler), \"Handle\", typeof(global::App.Confirmed))]"); + manifest.ShouldNotContain("typeof(object)"); + manifest.ShouldNotContain("global::App.Booked"); + ShouldCompile(body); + } + + [Fact] + public void a_method_with_no_candidates_produces_no_manifest() + { + const string body = @" +public static class NotAnEventHandler +{ + // Appends nothing a marker identifies: a plain List is not an ICarriesEvents. + public static List Handle(string command) + { + return new List { new Booked() }; + } +} + +public static class CandidateWithNoEvents +{ + // A candidate by parameter, but it appends nothing — still no entry. + public static void Handle(string command, IEventStream stream) { } +} +"; + FileNames(body).ShouldNotContain(ManifestFileName); + } + + [Fact] + public void the_manifest_is_skipped_when_jasperfx_events_is_not_referenced() + { + GeneratorHarness.GeneratedFileNamesWithoutJasperFxReference(@" +public static class Handler +{ + public static void Handle(string command) { } +} +").ShouldBeEmpty(); + } + + [Fact] + public void an_unrelated_edit_does_not_reanalyze_other_methods() + { + const string handlerFile = @" +using JasperFx.Events; + +namespace App; + +public static class ConfirmHandler +{ + public static void Handle(string command, IEventStream stream) + { + stream.AppendOne(new Confirmed()); + } +} +"; + const string otherFile = @" +namespace App; + +public static class Unrelated +{ + public static int Compute(int x) => x + 1; +} +"; + + var parseOptions = CSharpParseOptions.Default; + var compilation = CSharpCompilation.Create( + "IncrementalAssembly", + [ + CSharpSyntaxTree.ParseText(Preamble, parseOptions), + CSharpSyntaxTree.ParseText(handlerFile, parseOptions), + CSharpSyntaxTree.ParseText(otherFile, parseOptions) + ], + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(IEvent).Assembly.Location), + MetadataReference.CreateFromFile(System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll")), + MetadataReference.CreateFromFile(System.IO.Path.Combine( + System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Collections.dll")) + ], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new AggregateEvolverGenerator().AsSourceGenerator()], + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + + driver = driver.RunGenerators(compilation); + + var otherTree = compilation.SyntaxTrees.Last(); + var editedTree = otherTree.WithChangedText(Microsoft.CodeAnalysis.Text.SourceText.From( + otherFile.Replace("x + 1", "x + 2"))); + driver = driver.RunGenerators(compilation.ReplaceSyntaxTree(otherTree, editedTree)); + + var steps = driver.GetRunResult().Results.Single().TrackedSteps["JasperFxEmittedEvents"]; + var outputs = steps.SelectMany(s => s.Outputs).ToArray(); + + // The handler's analysis came out of the cache; nothing was recomputed for it. + outputs.ShouldNotBeEmpty(); + outputs.ShouldAllBe(o => o.Reason == IncrementalStepRunReason.Cached || o.Reason == IncrementalStepRunReason.Unchanged); + outputs.ShouldContain(o => o.Reason == IncrementalStepRunReason.Cached); + } +} + +/// +/// jasperfx#990, part 1 — pass 2's syntax filter let a method through only when a parameter +/// attribute had "Aggregate" in its name, so Wolverine's [WriteModel] / [ReadModel] / [DcbModel] +/// were never semantically checked for IRefersToAggregate at all. +/// +public class RefersToAggregateByMarkerNotNameTests +{ + // An aggregate built only through an event constructor declares no Apply/Create, so pass 1 + // never sees it — the parameter attribute is the only way it reaches the generator. + private const string Source = @" +using System; +using JasperFx.Events.Aggregation; + +namespace App; + +public class WriteModelAttribute : Attribute, IRefersToAggregate; + +public record AppointmentBooked(Guid AppointmentId); + +public class Appointment +{ + public Guid Id { get; set; } + + public Appointment(AppointmentBooked e) { Id = e.AppointmentId; } +} + +public static class BookHandler +{ + public static void Handle(string command, [WriteModel] Appointment appointment) { } +} +"; + + [Fact] + public void an_irefers_to_aggregate_attribute_without_aggregate_in_its_name_is_analyzed() + { + var (_, generatedSources) = GeneratorHarness.Run(Source); + + generatedSources.ShouldContain(s => s.Contains("typeof(global::App.Appointment)")); + GeneratorHarness.GeneratedCodeErrors(Source).ShouldBeEmpty(); + } +} diff --git a/src/JasperFx.Events.SourceGenerator/AggregateEvolverGenerator.cs b/src/JasperFx.Events.SourceGenerator/AggregateEvolverGenerator.cs index 0eb2dcce..bb35aa6a 100644 --- a/src/JasperFx.Events.SourceGenerator/AggregateEvolverGenerator.cs +++ b/src/JasperFx.Events.SourceGenerator/AggregateEvolverGenerator.cs @@ -24,7 +24,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Pipeline 2: aggregate types referenced by IRefersToAggregate parameters var refCandidates = context.SyntaxProvider.CreateSyntaxProvider( - predicate: static (node, _) => IsMethodWithPossibleAggregateAttribute(node), + predicate: static (node, _) => IsMethodWithAttributedParameter(node), transform: static (ctx, ct) => AnalyzeRefersToAggregateParameter(ctx, ct)) .Where(static info => info != null) .Collect(); @@ -63,8 +63,34 @@ public void Initialize(IncrementalGeneratorInitializationContext context) if (!available) return; spc.AddSource("JasperFxSourceGeneratorApplied.g.cs", MarkerSource); }); + + // Pipeline 4: the events each handler method appends, inferred from its body and recorded as + // an assembly-level manifest so Wolverine can stop asking users to write [Emits] + // (jasperfx#990). Each method reduces to a value-equatable EmittedEventsInfo, so an edit to one + // method leaves every other method's analysis cached. Gated on the manifest attribute existing, + // for the same reason as the marker above: an older JasperFx.Events must not turn into CS0246. + var emittedEvents = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => EmittedEventsAnalyzer.IsPossibleCandidate(node), + transform: static (ctx, ct) => EmittedEventsAnalyzer.Analyze(ctx, ct)) + .Where(static info => info != null) + .WithTrackingName(EmittedEventsTrackingName) + .Collect(); + + var manifestAvailable = context.CompilationProvider.Select(static (compilation, _) => + compilation.GetTypeByMetadataName(EmittedEventsAnalyzer.ManifestAttributeMetadataName) != null); + + context.RegisterSourceOutput(emittedEvents.Combine(manifestAvailable), static (spc, pair) => + { + if (!pair.Right || pair.Left.IsDefaultOrEmpty) return; + spc.AddSource(EmittedEventsFileName, EmittedEventsAnalyzer.EmitManifest(pair.Left)); + }); } + /// Tracking name of the per-method emitted-events step, for incrementality tests. + internal const string EmittedEventsTrackingName = "JasperFxEmittedEvents"; + + internal const string EmittedEventsFileName = "JasperFxEmittedEvents.g.cs"; + /// /// Mirrors JasperFx.Events.Aggregation.JasperFxSourceGeneratorAppliedAttribute, which this /// netstandard2.0 analyzer cannot reference. The attribute allows multiple applications on purpose, @@ -161,23 +187,18 @@ private static bool HasBoundaryAggregateAttributeSyntax(TypeDeclarationSyntax ty } /// - /// Lightweight syntax check for methods with parameters that have attributes - /// containing "Aggregate" in the name (catches ReadAggregate, WriteAggregate, etc.) + /// Lightweight syntax check: a method with at least one attributed parameter. Deliberately + /// name-blind — gates on the + /// IRefersToAggregate marker, and filtering here on "Aggregate" in the attribute name hid + /// Wolverine's [WriteModel] / [ReadModel] / [DcbModel] and any user alias from it. See jasperfx#990. /// - private static bool IsMethodWithPossibleAggregateAttribute(SyntaxNode node) + private static bool IsMethodWithAttributedParameter(SyntaxNode node) { if (node is not MethodDeclarationSyntax method) return false; foreach (var param in method.ParameterList.Parameters) { - foreach (var attrList in param.AttributeLists) - { - foreach (var attr in attrList.Attributes) - { - var name = attr.Name.ToString(); - if (name.Contains("Aggregate")) return true; - } - } + if (param.AttributeLists.Count > 0) return true; } return false; diff --git a/src/JasperFx.Events.SourceGenerator/EmittedEventsAnalyzer.cs b/src/JasperFx.Events.SourceGenerator/EmittedEventsAnalyzer.cs new file mode 100644 index 00000000..492c0b8e --- /dev/null +++ b/src/JasperFx.Events.SourceGenerator/EmittedEventsAnalyzer.cs @@ -0,0 +1,441 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; + +namespace JasperFx.Events.SourceGenerator; + +/// +/// One handler method's inferred events, reduced to strings so the incremental pipeline can compare +/// it by value. Holding symbols here would defeat caching: a symbol from one compilation never +/// equals the same symbol from the next, so every edit would look like a change to every method. +/// +internal sealed class EmittedEventsInfo : IEquatable +{ + public EmittedEventsInfo(string handlerType, string methodName, ImmutableArray eventTypes) + { + HandlerType = handlerType; + MethodName = methodName; + EventTypes = eventTypes; + } + + /// Fully qualified, global::-rooted. + public string HandlerType { get; } + + public string MethodName { get; } + + /// Fully qualified, global::-rooted, deduplicated, in order of first appearance. + public ImmutableArray EventTypes { get; } + + public bool Equals(EmittedEventsInfo? other) + { + if (other is null) return false; + return HandlerType == other.HandlerType + && MethodName == other.MethodName + && EventTypes.SequenceEqual(other.EventTypes); + } + + public override bool Equals(object? obj) => Equals(obj as EmittedEventsInfo); + + public override int GetHashCode() + { + unchecked + { + var hash = HandlerType.GetHashCode() * 397 ^ MethodName.GetHashCode(); + foreach (var type in EventTypes) hash = hash * 31 + type.GetHashCode(); + return hash; + } + } +} + +/// +/// Infers the events a handler method appends from its body, keyed off JasperFx.Events marker types +/// rather than Wolverine's attribute or type names. See jasperfx#990. +/// +internal static class EmittedEventsAnalyzer +{ + private const string EventStreamMetadataName = "JasperFx.Events.IEventStream"; + private const string CarriesEventsMetadataName = "JasperFx.Events.ICarriesEvents"; + public const string ManifestAttributeMetadataName = "JasperFx.Events.EmittedEventsAttribute"; + + /// + /// Instance members of an type that add to it. Matched by + /// name because the marker says nothing about which members mutate: Contains(object) has + /// the same event-shaped parameter as Add(object). + /// + private static readonly HashSet AddingMethodNames = new() + { + "Add", "AddRange", "Insert", "InsertRange", "Append", "AppendOne", "AppendMany" + }; + + /// + /// Syntax predicate. Cheap and name-blind: a method with a body that either takes a parameter or + /// returns something — the only shapes that can satisfy any candidate rule. Everything else is + /// decided semantically in . + /// + public static bool IsPossibleCandidate(SyntaxNode node) + { + if (node is not MethodDeclarationSyntax method) return false; + if (method.Body is null && method.ExpressionBody is null) return false; + if (method.ExplicitInterfaceSpecifier != null) return false; + + if (method.ParameterList.Parameters.Count > 0) return true; + + return !(method.ReturnType is PredefinedTypeSyntax predefined + && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword)); + } + + public static EmittedEventsInfo? Analyze(GeneratorSyntaxContext ctx, CancellationToken ct) + { + var syntax = (MethodDeclarationSyntax)ctx.Node; + if (ctx.SemanticModel.GetDeclaredSymbol(syntax, ct) is not IMethodSymbol method) return null; + + // The manifest names the handler with typeof() at assembly scope, so the type has to be + // nameable from there — and the issue scopes this to public types in any case. + if (!IsPublicNonGeneric(method.ContainingType)) return null; + if (method.IsGenericMethod) return null; + + if (!IsCandidate(method)) return null; + + var body = (SyntaxNode?)syntax.Body ?? syntax.ExpressionBody; + if (body is null) return null; + + var operation = ctx.SemanticModel.GetOperation(body, ct); + if (operation is null) return null; + + var collector = new EventCollector(ctx.SemanticModel, ct); + foreach (var descendant in operation.DescendantsAndSelf()) + { + ct.ThrowIfCancellationRequested(); + if (descendant is IInvocationOperation invocation) collector.Visit(invocation); + else if (descendant is IObjectCreationOperation creation) collector.Visit(creation); + } + + if (collector.Events.Count == 0) return null; + + return new EmittedEventsInfo( + method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + method.Name, + collector.Events.ToImmutableArray()); + } + + private static bool IsCandidate(IMethodSymbol method) + { + foreach (var parameter in method.Parameters) + { + if (IsEventStream(parameter.Type)) return true; + + foreach (var attribute in parameter.GetAttributes()) + { + if (attribute.AttributeClass != null + && AggregateAnalyzer.ImplementsIRefersToAggregate(attribute.AttributeClass)) + { + return true; + } + } + } + + var returnType = UnwrapTask(method.ReturnType); + if (returnType is INamedTypeSymbol { IsTupleType: true } tuple) + { + return tuple.TupleElements.Any(e => CarriesEvents(UnwrapTask(e.Type))); + } + + return CarriesEvents(returnType); + } + + private static ITypeSymbol UnwrapTask(ITypeSymbol type) + { + if (type is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1 } named) + { + var definition = named.ConstructedFrom.ToDisplayString(); + if (definition is "System.Threading.Tasks.Task" or "System.Threading.Tasks.ValueTask") + { + return named.TypeArguments[0]; + } + } + + return type; + } + + /// + /// JasperFx.Events' own IEventStream<T>, or a store's interface that inherits it — + /// matched by symbol, so no marker is needed for this case. + /// + private static bool IsEventStream(ITypeSymbol? type) + { + if (type is not INamedTypeSymbol named) return false; + if (IsEventStreamDefinition(named)) return true; + return named.AllInterfaces.Any(IsEventStreamDefinition); + } + + private static bool IsEventStreamDefinition(INamedTypeSymbol type) + { + return type.IsGenericType && type.ConstructedFrom.ToDisplayString() == EventStreamMetadataName; + } + + private static bool CarriesEvents(ITypeSymbol? type) + { + if (type is null || type is ITypeParameterSymbol) return false; + if (type.ToDisplayString() == CarriesEventsMetadataName) return true; + return type.AllInterfaces.Any(i => i.ToDisplayString() == CarriesEventsMetadataName); + } + + /// + /// Public all the way out and closed: the only shape a typeof at assembly scope can name + /// that is also within the issue's "public type" scope. + /// + private static bool IsPublicNonGeneric(INamedTypeSymbol? type) + { + for (var current = type; current != null; current = current.ContainingType) + { + if (current.DeclaredAccessibility != Accessibility.Public) return false; + if (current.IsGenericType) return false; + } + + return type != null; + } + + private sealed class EventCollector + { + private readonly SemanticModel _model; + private readonly CancellationToken _ct; + private readonly HashSet _seen = new(); + + public EventCollector(SemanticModel model, CancellationToken ct) + { + _model = model; + _ct = ct; + } + + public List Events { get; } = new(); + + public void Visit(IInvocationOperation invocation) + { + var method = invocation.TargetMethod; + var receiver = invocation.Instance?.Type; + + var appendsToStream = method.Name is "AppendOne" or "AppendMany" && IsEventStream(receiver); + var addsToCarrier = AddingMethodNames.Contains(method.Name) && CarriesEvents(receiver); + var buildsCarrier = CarriesEvents(method.ReturnType); + + if (appendsToStream || addsToCarrier || buildsCarrier) + { + CollectArguments(invocation.Arguments); + } + } + + public void Visit(IObjectCreationOperation creation) + { + // The collection-initializer half (`new Events { new A() }`) needs nothing here: each + // element is an implicit Add() invocation on the new instance, which Visit(invocation) + // already sees as an add to an ICarriesEvents receiver. + if (CarriesEvents(creation.Type)) + { + CollectArguments(creation.Arguments); + } + } + + private void CollectArguments(ImmutableArray arguments) + { + foreach (var argument in arguments) + { + var parameter = argument.Parameter; + if (parameter is null) continue; + + if (argument.ArgumentKind == ArgumentKind.ParamArray) + { + // Expanded `params object[]`: the compiler built the array, one element per event. + if (IsObject(ElementTypeOf(parameter.Type)) && argument.Value is IArrayCreationOperation array) + { + CollectElements(array); + } + + continue; + } + + if (IsObject(parameter.Type)) + { + CollectValue(argument.Value); + } + else if (IsObject(ElementTypeOf(parameter.Type))) + { + CollectSequence(argument.Value); + } + + // Anything else — a Guid or string stream id, an index — is not an event slot. + } + } + + /// A value passed where one event is expected. + private void CollectValue(IOperation value) + { + value = UnwrapImplicitConversions(value); + + if (value is IConditionalOperation conditional) + { + if (conditional.WhenTrue != null) CollectValue(conditional.WhenTrue); + if (conditional.WhenFalse != null) CollectValue(conditional.WhenFalse); + return; + } + + if (value is ICoalesceOperation coalesce) + { + CollectValue(coalesce.Value); + CollectValue(coalesce.WhenNull); + return; + } + + Add(value.Type); + } + + /// A value passed where a sequence of events is expected. + private void CollectSequence(IOperation value) + { + value = UnwrapImplicitConversions(value); + + if (value is IArrayCreationOperation array) + { + CollectElements(array); + return; + } + + // Collection expressions (`[new A(), new B()]`) have no IOperation shape in the Roslyn + // this analyzer targets, so read their elements from syntax. + if (value.Syntax is CollectionExpressionSyntax collection) + { + foreach (var element in collection.Elements.OfType()) + { + var elementOperation = _model.GetOperation(element.Expression, _ct); + if (elementOperation != null) CollectValue(elementOperation); + } + + return; + } + + // A typed sequence (List, A[]) still names its events through its element type. + Add(ElementTypeOf(value.Type)); + } + + private void CollectElements(IArrayCreationOperation array) + { + if (array.Initializer is null) return; + foreach (var element in array.Initializer.ElementValues) + { + CollectValue(element); + } + } + + private void Add(ITypeSymbol? type) + { + if (!IsNameableEventType(type)) return; + + var name = type!.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + if (_seen.Add(name)) Events.Add(name); + } + } + + private static IOperation UnwrapImplicitConversions(IOperation operation) + { + while (operation is IConversionOperation { IsImplicit: true } conversion) + { + operation = conversion.Operand; + } + + return operation; + } + + private static bool IsObject(ITypeSymbol? type) => type?.SpecialType == SpecialType.System_Object; + + /// The element type of an array or of the first IEnumerable<T> the type is or implements. + private static ITypeSymbol? ElementTypeOf(ITypeSymbol? type) + { + if (type is IArrayTypeSymbol array) return array.ElementType; + if (type is not INamedTypeSymbol named) return null; + + static bool IsEnumerableOfT(INamedTypeSymbol t) => + t.IsGenericType && t.ConstructedFrom.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T; + + if (IsEnumerableOfT(named)) return named.TypeArguments[0]; + + var enumerable = named.AllInterfaces.FirstOrDefault(IsEnumerableOfT); + return enumerable?.TypeArguments[0]; + } + + /// + /// Whether the static type says anything (object does not) and can be named by a + /// typeof at assembly scope: no type parameters, nothing private or protected on the way out. + /// + private static bool IsNameableEventType(ITypeSymbol? type) + { + if (type is not INamedTypeSymbol named) return false; + if (named.TypeKind == TypeKind.Error || named.IsAnonymousType) return false; + if (named.SpecialType == SpecialType.System_Object) return false; + + return IsNameableFromAssemblyScope(named); + } + + private static bool IsNameableFromAssemblyScope(INamedTypeSymbol type) + { + foreach (var argument in type.TypeArguments) + { + if (argument is not INamedTypeSymbol namedArgument) return false; + if (!IsNameableFromAssemblyScope(namedArgument)) return false; + } + + for (var current = type; current != null; current = current.ContainingType) + { + if (current.DeclaredAccessibility is not (Accessibility.Public or Accessibility.Internal + or Accessibility.ProtectedOrInternal)) + { + return false; + } + } + + return true; + } + + public static string EmitManifest(ImmutableArray infos) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("// Emitted by JasperFx.Events.SourceGenerator: the events each handler method was inferred"); + builder.AppendLine("// to append, read from its body. A lower bound — an event typed `object` is left out."); + builder.AppendLine("// See jasperfx#990."); + + // Overloads share a name, and the manifest keys on the name, so merge them into one entry. + var merged = new Dictionary<(string, string), List>(); + var order = new List<(string, string)>(); + foreach (var info in infos) + { + if (info is null) continue; + var key = (info.HandlerType, info.MethodName); + if (!merged.TryGetValue(key, out var events)) + { + merged[key] = events = new List(); + order.Add(key); + } + + foreach (var type in info.EventTypes) + { + if (!events.Contains(type)) events.Add(type); + } + } + + foreach (var key in order) + { + var (handler, methodName) = key; + var typeofs = string.Join(", ", merged[key].Select(t => $"typeof({t})")); + builder.AppendLine( + $"[assembly: global::JasperFx.Events.EmittedEvents(typeof({handler}), \"{methodName}\", {typeofs})]"); + } + + return builder.ToString(); + } +} diff --git a/src/JasperFx.Events/EmittedEventsAttribute.cs b/src/JasperFx.Events/EmittedEventsAttribute.cs new file mode 100644 index 00000000..bcff833d --- /dev/null +++ b/src/JasperFx.Events/EmittedEventsAttribute.cs @@ -0,0 +1,40 @@ +namespace JasperFx.Events; + +/// +/// Assembly-level manifest entry, emitted by the JasperFx.Events source generator, recording the +/// event types a handler method was inferred to append from its body. One entry per method. +/// +/// +/// +/// The generator infers from / +/// calls and from the code that builds an +/// value. An event it cannot see statically — one typed object, +/// say — is left out, so the manifest is a lower bound, never a complete list. Consumers that let +/// users declare emitted events explicitly should union the two. +/// +/// +/// Only typeof tokens are used, so reading the manifest is AOT-safe: one attribute lookup per +/// assembly. Overloads of one method name share entries keyed by that name. AllowMultiple is +/// also what keeps a double-loaded analyzer (jasperfx#462) from failing the build with CS0579. +/// See jasperfx#990. +/// +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] +public sealed class EmittedEventsAttribute : Attribute +{ + public EmittedEventsAttribute(Type handlerType, string methodName, params Type[] eventTypes) + { + HandlerType = handlerType; + MethodName = methodName; + EventTypes = eventTypes; + } + + /// The type declaring the handler method. + public Type HandlerType { get; } + + /// The handler method's name. + public string MethodName { get; } + + /// The event types inferred from the method body, in order of first appearance. + public Type[] EventTypes { get; } +} diff --git a/src/JasperFx.Events/ICarriesEvents.cs b/src/JasperFx.Events/ICarriesEvents.cs new file mode 100644 index 00000000..ea6514d3 --- /dev/null +++ b/src/JasperFx.Events/ICarriesEvents.cs @@ -0,0 +1,14 @@ +namespace JasperFx.Events; + +/// +/// Marks a type whose values carry events to append, where the type itself doesn't name them: +/// a stream start, an append batch. The JasperFx.Events source generator reads the event types +/// from the code that builds the value and records them in an +/// manifest. The interface changes no behavior. +/// +/// +/// The generator treats a member of a marked type as event-carrying when its parameter is typed +/// object or as a collection of object — so a stream id typed Guid or +/// string beside the events is never mistaken for one. See jasperfx#990. +/// +public interface ICarriesEvents;