diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs
new file mode 100644
index 00000000..7dea08ce
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationExtensionsEmitter.cs
@@ -0,0 +1,73 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// Emits the strongly typed Events() overloads that replace the placeholder at each call site.
+internal static class ActivationExtensionsEmitter
+{
+ /// The room to reserve for the file's fixed scaffolding.
+ private const int ScaffoldCapacity = 256;
+
+ /// The room to reserve per generated overload.
+ private const int OverloadCapacity = 256;
+
+ /// The indentation the generated overloads sit at, inside a namespace and a class.
+ private const int MethodIndent = SourceFileWriter.IndentWidth + SourceFileWriter.IndentWidth;
+
+ /// The indentation the generated overloads' bodies and constraints sit at.
+ private const int BodyIndent = MethodIndent + SourceFileWriter.IndentWidth;
+
+ /// Emits every generated activation overload into one partial-class file.
+ /// The overloads to emit, in request order.
+ /// The generated source.
+ ///
+ /// The overloads join the same partial class as the placeholder they displace, so a call site resolves to the
+ /// concrete overload without the consumer importing anything new: a non-generic candidate beats the generic
+ /// placeholder outright.
+ ///
+ internal static string Emit(ImmutableArray models)
+ {
+ var builder = new PooledStringBuilder(ScaffoldCapacity + (models.Length * OverloadCapacity));
+ _ = builder.Append(Constants.GeneratedFileHeader);
+
+ // Every overload was extracted from the same compilation, so they agree on what its language allows.
+ if (models[0].SupportsNullableAnnotations)
+ {
+ _ = builder.Append(Constants.NullableEnableDirective);
+ }
+
+ _ = builder.Append("namespace ").AppendLine(Constants.GeneratedNamespace)
+ .AppendLine("{")
+ .AppendIndent(SourceFileWriter.IndentWidth).Append("internal static partial class ")
+ .AppendLine(Constants.ActivationExtensionsClassName)
+ .AppendIndent(SourceFileWriter.IndentWidth).AppendLine("{");
+
+ foreach (var model in models)
+ {
+ AppendOverload(builder, model);
+ }
+
+ _ = builder.AppendIndent(SourceFileWriter.IndentWidth).AppendLine("}").AppendLine("}");
+ return builder.ToStringAndReturn();
+ }
+
+ /// Appends one activation overload.
+ /// The destination builder.
+ /// The overload to append.
+ private static void AppendOverload(PooledStringBuilder builder, ActivationModel model) =>
+ _ = builder.AppendIndent(MethodIndent)
+ .Append("/// Gets observable wrappers for public events on ")
+ .Append(model.DocumentationName).AppendLine(".")
+ .AppendIndent(MethodIndent).Append("public static ").Append(model.WrapperReference)
+ .Append(" Events").Append(model.TypeParameterList).Append("(this ").Append(model.TypeReference)
+ .AppendLine(" eventHost)")
+ .AppendIndentedLines(model.Constraints, BodyIndent)
+ .AppendIndent(BodyIndent).Append("=> new ")
+ .Append(model.WrapperReference).AppendLine("(eventHost);")
+ .AppendLine();
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs
new file mode 100644
index 00000000..e25c691c
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/ActivationSource.cs
@@ -0,0 +1,56 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// The API a consumer writes against, injected before anything is scanned.
+///
+///
+/// This is what makes the generator opt-in without a package-level runtime dependency: the placeholder
+/// Events<T> gives a call site something to bind to while it is being typed, and the generated
+/// overload for the receiver's own type displaces it once the type resolves. The attribute has to exist here too,
+/// because a static host has no receiver to hang a call off.
+///
+///
+/// Nothing here is annotated, and it deliberately carries no #nullable directive. Post-initialization
+/// output is produced before anything about the consumer is known, including the language version, so this is the
+/// one generated file that cannot ask whether the consumer could compile an annotation - and it has no reason to.
+///
+///
+internal static class ActivationSource
+{
+ /// The source injected during post-initialization.
+ internal const string Text = """
+ //
+ namespace ReactiveUI.Primitives.ObservableEvents
+ {
+ /// Requests observable wrappers for public static events on a type.
+ [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, AllowMultiple = true)]
+ internal sealed class GenerateStaticEventObservablesAttribute : global::System.Attribute
+ {
+ /// Initializes the request.
+ /// The static event host.
+ public GenerateStaticEventObservablesAttribute(global::System.Type type) => Type = type;
+
+ /// Gets the requested static event host.
+ public global::System.Type Type { get; }
+ }
+
+ /// Contains activation extensions used by the observable-event generator.
+ internal static partial class ObservableGeneratorExtensions
+ {
+ /// Requests observable wrappers for the receiver's public events.
+ /// The event host type.
+ /// The event host.
+ /// A placeholder replaced by a generated, strongly typed overload.
+ public static NullEvents Events(this T eventHost) => default;
+ }
+
+ /// Placeholder returned until a strongly typed event wrapper is generated.
+ internal readonly struct NullEvents
+ {
+ }
+ }
+ """;
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs
new file mode 100644
index 00000000..9987ec8d
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/GeneratedNames.cs
@@ -0,0 +1,125 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using System.Runtime.CompilerServices;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// Builds the wrapper class and generated file names, from the identity of the host they belong to.
+///
+/// A readable name alone is not enough to key generated output on. Sanitizing punctuation out of a fully qualified
+/// name maps distinct hosts onto the same identifier - Samples.A_B.C and Samples.A.B_C both flatten to
+/// Samples_A_B_C - so a hash of the unflattened identity is appended to keep them apart, while the readable
+/// half is kept so a generated file is still recognisable in a build log.
+///
+internal static class GeneratedNames
+{
+ /// The prefix of a generated wrapper class name.
+ private const string WrapperPrefix = "Rx";
+
+ /// The suffix of a generated wrapper class name.
+ private const string WrapperSuffix = "Events";
+
+ /// The prefix shared by every generated file name.
+ private const string HintPrefix = "ObservableEvents.";
+
+ /// The suffix of a generated instance wrapper file name.
+ private const string InstanceHintSuffix = ".Instance.g.cs";
+
+ /// The suffix of a generated static wrapper file name.
+ private const string StaticHintSuffix = ".Static.g.cs";
+
+ /// The offset basis of the FNV-1a hash that keeps sanitized names apart.
+ private const ulong HashOffsetBasis = 14_695_981_039_346_656_037;
+
+ /// The prime of the FNV-1a hash that keeps sanitized names apart.
+ private const ulong HashPrime = 1_099_511_628_211;
+
+ /// Builds the wrapper class name for a host.
+ /// The host's fully qualified name.
+ /// The wrapper class name.
+ internal static string WrapperName(string identity)
+ {
+ var builder = new PooledStringBuilder(identity.Length + WrapperPrefix.Length + WrapperSuffix.Length);
+ _ = builder.Append(WrapperPrefix);
+ AppendUniqueComponent(builder, identity);
+ return builder.Append(WrapperSuffix).ToStringAndReturn();
+ }
+
+ /// Builds the generated file name for an instance wrapper.
+ /// The host's fully qualified name.
+ /// The generated file name.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static string InstanceHintName(string identity) => HintName(identity, InstanceHintSuffix);
+
+ /// Builds the generated file name for one namespace's static wrappers.
+ /// The namespace, or an empty string for the global namespace.
+ /// The generated file name.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static string StaticHintName(string namespaceName) => HintName(namespaceName, StaticHintSuffix);
+
+ /// Appends the readable, collision-resistant component of a generated name.
+ /// The destination builder.
+ /// The identity to render.
+ private static void AppendUniqueComponent(PooledStringBuilder builder, string identity)
+ {
+ AppendSanitized(builder, identity);
+ _ = builder.Append('_').Append(StableHash(identity));
+ }
+
+ /// Appends an identity with everything that cannot appear in an identifier folded to underscores.
+ /// The destination builder.
+ /// The identity to sanitize.
+ private static void AppendSanitized(PooledStringBuilder builder, string identity)
+ {
+ var start = 0;
+ var end = identity.Length;
+
+ // Leading and trailing punctuation would sanitize to underscores that carry no information, and a
+ // generated name reads better without them; the hash still separates identities that differ only there.
+ while (start < end && !char.IsLetterOrDigit(identity[start]))
+ {
+ start++;
+ }
+
+ while (end > start && !char.IsLetterOrDigit(identity[end - 1]))
+ {
+ end--;
+ }
+
+ for (var index = start; index < end; index++)
+ {
+ var character = identity[index];
+ _ = builder.Append(char.IsLetterOrDigit(character) ? character : '_');
+ }
+ }
+
+ /// Builds a generated file name from an identity and a category suffix.
+ /// The identity the file is keyed on.
+ /// The category suffix.
+ /// The generated file name.
+ private static string HintName(string identity, string suffix)
+ {
+ var builder = new PooledStringBuilder(identity.Length + HintPrefix.Length + suffix.Length);
+ _ = builder.Append(HintPrefix);
+ AppendUniqueComponent(builder, identity);
+ return builder.Append(suffix).ToStringAndReturn();
+ }
+
+ /// Computes a deterministic FNV-1a hash of an identity.
+ /// The identity to hash.
+ /// The invariant uppercase hexadecimal hash.
+ private static string StableHash(string identity)
+ {
+ var hash = HashOffsetBasis;
+ foreach (var character in identity)
+ {
+ hash ^= character;
+ hash *= HashPrime;
+ }
+
+ return hash.ToString("X16", CultureInfo.InvariantCulture);
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs
new file mode 100644
index 00000000..698d9d1a
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/InstanceWrapperEmitter.cs
@@ -0,0 +1,56 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// Emits the wrapper class that exposes one host's instance events as observables.
+internal static class InstanceWrapperEmitter
+{
+ /// The room to reserve for the file's fixed scaffolding.
+ private const int ScaffoldCapacity = 512;
+
+ /// The room to reserve per generated observable property.
+ private const int PropertyCapacity = 512;
+
+ /// Emits the wrapper for one host.
+ /// The host to wrap.
+ /// The observable implementation to write against.
+ /// The generated source.
+ ///
+ /// The host is held in a field rather than resubscribed from a captured expression, so every property on one
+ /// wrapper observes the same instance the consumer handed it.
+ ///
+ internal static string Emit(InstanceTargetModel model, ObservableProvider provider)
+ {
+ var events = model.Events.AsArray();
+ var builder = new PooledStringBuilder(ScaffoldCapacity + (events.Length * PropertyCapacity));
+ var indent = SourceFileWriter.AppendHeader(
+ builder,
+ model.Namespace,
+ model.SupportsNullableAnnotations);
+ var memberIndent = indent + SourceFileWriter.IndentWidth;
+
+ _ = builder.AppendIndent(indent).Append("internal sealed class ").Append(model.WrapperName)
+ .AppendLine(model.TypeParameterList)
+ .AppendIndentedLines(model.Constraints, memberIndent)
+ .AppendIndent(indent).AppendLine("{")
+ .AppendIndent(memberIndent).Append("private readonly ").Append(model.TypeReference)
+ .AppendLine(" _host;")
+ .AppendLine()
+ .AppendIndent(memberIndent).Append("internal ").Append(model.WrapperName).Append('(')
+ .Append(model.TypeReference).AppendLine(" host) => _host = host;")
+ .AppendLine();
+
+ foreach (var eventModel in events)
+ {
+ SourceFileWriter.AppendEventProperty(builder, eventModel, provider, memberIndent);
+ }
+
+ _ = builder.AppendIndent(indent).AppendLine("}");
+ SourceFileWriter.AppendFooter(builder, model.Namespace);
+ return builder.ToStringAndReturn();
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs
new file mode 100644
index 00000000..4338bcdd
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/PooledStringBuilder.cs
@@ -0,0 +1,289 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// A fluent builder for generated source, backed by thread-local pooled character buffers.
+///
+///
+/// Emission builds a great many short fragments - a payload type here, a handler parameter list there - and one
+/// large file per target. Accumulating into a pooled char[] lets the same buffers carry every fragment and
+/// every file in a pass, so the steady state is a handful of arrays rather than a builder and its grown chunk
+/// chain per fragment.
+///
+///
+/// The free list is thread-local rather than a shared pool: source-output callbacks run concurrently, fragment
+/// builders nest inside file builders, and nothing here outlives the call that rented it. Returning is what buys
+/// the reuse; forgetting to costs reuse, never correctness.
+///
+///
+internal sealed class PooledStringBuilder
+{
+ /// The smallest buffer worth renting, sized to hold a typical fragment without growing.
+ private const int DefaultCapacity = 256;
+
+ /// The factor the buffer grows by when exhausted.
+ private const int GrowthFactor = 2;
+
+ /// The number of buffers cached per thread, covering how deeply emission nests fragments.
+ private const int MaxPooledPerThread = 16;
+
+ /// The base of the decimal rendering used by .
+ private const int DecimalBase = 10;
+
+ /// The widest decimal rendering of a non-negative .
+ private const int MaxIntegerDigits = 10;
+
+ /// The line terminator, fixed so generated output does not vary by host platform.
+ private const char NewLine = '\n';
+
+ /// The per-thread free list of reusable buffers.
+ [ThreadStatic]
+ private static char[][]? _pool;
+
+ /// The number of populated slots in .
+ [ThreadStatic]
+ private static int _pooledCount;
+
+ /// The pooled array currently backing this builder.
+ private char[] _buffer;
+
+ /// The write position within .
+ private int _position;
+
+ /// Initializes a new instance of the class.
+ /// The capacity to rent up front.
+ internal PooledStringBuilder(int capacity = DefaultCapacity) =>
+ _buffer = RentBuffer(capacity < DefaultCapacity ? DefaultCapacity : capacity);
+
+ /// Gets the number of characters accumulated so far.
+ internal int Length => _position;
+
+ /// Materializes the accumulated content, leaving the builder usable.
+ /// The accumulated string.
+ public override string ToString() => _position == 0 ? string.Empty : new string(_buffer, 0, _position);
+
+ /// Appends a string.
+ /// The string to append, which may be null or empty.
+ /// This builder, for chaining.
+ internal PooledStringBuilder Append(string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return this;
+ }
+
+ EnsureCapacity(_position + value!.Length);
+ value.CopyTo(0, _buffer, _position, value.Length);
+ _position += value.Length;
+ return this;
+ }
+
+ /// Appends a single character.
+ /// The character to append.
+ /// This builder, for chaining.
+ internal PooledStringBuilder Append(char value)
+ {
+ EnsureCapacity(_position + 1);
+ _buffer[_position] = value;
+ _position++;
+ return this;
+ }
+
+ /// Appends the invariant decimal rendering of a non-negative integer.
+ /// The value to append; the only callers pass a name length, so it is never negative.
+ /// This builder, for chaining.
+ ///
+ /// Formats digits straight into the buffer. These appends sit in the per-event loop that builds the mangled
+ /// static property names, where going through ToString would allocate a string per name segment.
+ ///
+ internal PooledStringBuilder Append(int value)
+ {
+ EnsureCapacity(_position + MaxIntegerDigits);
+
+ var remaining = value;
+ var digitStart = _position;
+
+ do
+ {
+ _buffer[_position] = (char)('0' + (remaining % DecimalBase));
+ _position++;
+ remaining /= DecimalBase;
+ }
+ while (remaining != 0);
+
+ ReverseDigits(digitStart);
+ return this;
+ }
+
+ /// Appends another builder's content, then returns that builder's buffer to the pool.
+ /// The fragment builder to drain; it must not be appended to afterwards.
+ /// This builder, for chaining.
+ /// Copies buffer to buffer, so a nested fragment joins its file without materializing a string.
+ internal PooledStringBuilder Append(PooledStringBuilder other)
+ {
+ if (other._position != 0)
+ {
+ EnsureCapacity(_position + other._position);
+ Array.Copy(other._buffer, 0, _buffer, _position, other._position);
+ _position += other._position;
+ }
+
+ other.Return();
+ return this;
+ }
+
+ /// Appends a line terminator.
+ /// This builder, for chaining.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal PooledStringBuilder AppendLine() => Append(NewLine);
+
+ /// Appends a string followed by a line terminator.
+ /// The string to append, which may be null or empty.
+ /// This builder, for chaining.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal PooledStringBuilder AppendLine(string? value) => Append(value).Append(NewLine);
+
+ /// Appends the requested number of leading spaces.
+ /// The indentation width.
+ /// This builder, for chaining.
+ internal PooledStringBuilder AppendIndent(int spaces)
+ {
+ EnsureCapacity(_position + spaces);
+ for (var index = 0; index < spaces; index++)
+ {
+ _buffer[_position] = ' ';
+ _position++;
+ }
+
+ return this;
+ }
+
+ /// Appends a block of newline-separated lines, indenting each non-empty one.
+ /// The block to append, which may be empty.
+ /// The indentation width applied to every non-empty line.
+ /// This builder, for chaining.
+ ///
+ /// Blank lines are left bare rather than filled with spaces, so an indented block never carries trailing
+ /// whitespace into the generated file.
+ ///
+ internal PooledStringBuilder AppendIndentedLines(string value, int spaces)
+ {
+ var start = 0;
+ while (start < value.Length)
+ {
+ var end = value.IndexOf(NewLine, start);
+ if (end < 0)
+ {
+ end = value.Length;
+ }
+
+ if (end > start)
+ {
+ _ = AppendIndent(spaces);
+ EnsureCapacity(_position + (end - start));
+ value.CopyTo(start, _buffer, _position, end - start);
+ _position += end - start;
+ }
+
+ _ = AppendLine();
+ start = end + 1;
+ }
+
+ return this;
+ }
+
+ /// Hands the buffer back to the thread's free list.
+ /// The builder must not be appended to afterwards.
+ internal void Return()
+ {
+ var toReturn = _buffer;
+ _buffer = [];
+ _position = 0;
+ ReturnBuffer(toReturn);
+ }
+
+ /// Materializes the accumulated content and hands the buffer back.
+ /// The accumulated string.
+ internal string ToStringAndReturn()
+ {
+ var result = ToString();
+ Return();
+ return result;
+ }
+
+ /// Takes a buffer of at least the requested length from the thread's free list, or allocates one.
+ /// The minimum length required.
+ /// A buffer at least long.
+ private static char[] RentBuffer(int minimumLength)
+ {
+ var pool = _pool;
+ if (pool is not null)
+ {
+ for (var index = _pooledCount - 1; index >= 0; index--)
+ {
+ var candidate = pool[index];
+ if (candidate.Length < minimumLength)
+ {
+ continue;
+ }
+
+ _pooledCount--;
+ pool[index] = pool[_pooledCount];
+ pool[_pooledCount] = null!;
+ return candidate;
+ }
+ }
+
+ return new char[minimumLength];
+ }
+
+ /// Puts a buffer back on the thread's free list, dropping it when the list is full.
+ /// The buffer to return.
+ private static void ReturnBuffer(char[] buffer)
+ {
+ if (buffer.Length == 0)
+ {
+ return;
+ }
+
+ var pool = _pool ??= new char[MaxPooledPerThread][];
+ if (_pooledCount >= MaxPooledPerThread)
+ {
+ return;
+ }
+
+ pool[_pooledCount] = buffer;
+ _pooledCount++;
+ }
+
+ /// Grows the buffer when the requested length no longer fits.
+ /// The total capacity required.
+ private void EnsureCapacity(int required)
+ {
+ if (required <= _buffer.Length)
+ {
+ return;
+ }
+
+ // Doubling unless the caller asked for more outright, so a run of small appends does not re-rent per append.
+ var next = RentBuffer(Math.Max(required, _buffer.Length * GrowthFactor));
+ Array.Copy(_buffer, next, _position);
+ var toReturn = _buffer;
+ _buffer = next;
+ ReturnBuffer(toReturn);
+ }
+
+ /// Reverses the digits written from a position, which were emitted least significant first.
+ /// The index the digits start at.
+ private void ReverseDigits(int start)
+ {
+ for (var end = _position - 1; start < end; start++, end--)
+ {
+ (_buffer[end], _buffer[start]) = (_buffer[start], _buffer[end]);
+ }
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs
new file mode 100644
index 00000000..66e27960
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/SourceFileWriter.cs
@@ -0,0 +1,102 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using ReactiveUI.Primitives.ObservableEvents.Helpers;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// Writes the pieces every generated file shares: its header, its namespace, and its event properties.
+internal static class SourceFileWriter
+{
+ /// The width of one level of indentation.
+ internal const int IndentWidth = 4;
+
+ /// Opens a generated file, wrapping it in a namespace unless the host lives in the global one.
+ /// The destination builder.
+ /// The namespace, or an empty string for the global namespace.
+ /// Whether the consumer's language can express an annotation.
+ /// The indentation the file's top-level declaration sits at.
+ internal static int AppendHeader(
+ PooledStringBuilder builder,
+ string namespaceName,
+ bool supportsNullableAnnotations)
+ {
+ _ = builder.Append(Constants.GeneratedFileHeader);
+ if (supportsNullableAnnotations)
+ {
+ _ = builder.Append(Constants.NullableEnableDirective);
+ }
+
+ if (namespaceName.Length == 0)
+ {
+ return 0;
+ }
+
+ _ = builder.Append("namespace ").AppendLine(namespaceName).AppendLine("{");
+ return IndentWidth;
+ }
+
+ /// Closes the namespace a generated file was opened with, when there was one.
+ /// The destination builder.
+ /// The namespace, or an empty string for the global namespace.
+ internal static void AppendFooter(PooledStringBuilder builder, string namespaceName)
+ {
+ if (namespaceName.Length == 0)
+ {
+ return;
+ }
+
+ _ = builder.AppendLine("}");
+ }
+
+ /// Appends the observable property that wraps one event.
+ /// The destination builder.
+ /// The event to wrap.
+ /// The observable implementation to write against.
+ /// The indentation the property sits at.
+ ///
+ /// The handler is a local function rather than a lambda so a delegate returning Task or
+ /// ValueTask can satisfy its own signature; the subscription is torn down through the provider's own
+ /// disposable factory, so the wrapper never holds the handler alive past the subscription.
+ ///
+ internal static void AppendEventProperty(
+ PooledStringBuilder builder,
+ EventModel model,
+ ObservableProvider provider,
+ int indent)
+ {
+ var payloadType = model.HasVoidPayload ? ProviderResolver.VoidPayloadType(provider) : model.PayloadType;
+ var payloadValue = model.HasVoidPayload ? payloadType + Constants.DefaultMember : model.PayloadValue;
+ var bodyIndent = indent + IndentWidth;
+ var statementIndent = bodyIndent + IndentWidth;
+
+ _ = builder.AppendIndent(indent)
+ .Append("/// Gets an observable that signals when the ")
+ .Append(model.DocumentationName).AppendLine(" event is raised.")
+ .AppendIndent(indent).Append("public ").Append(model.IsStatic ? "static " : string.Empty)
+ .Append("global::System.IObservable<").Append(payloadType).Append("> ")
+ .Append(model.PropertyName).Append(" => ").Append(ProviderResolver.ObservableFactory(provider))
+ .Append('<').Append(payloadType).AppendLine(">(observer =>")
+ .AppendIndent(indent).AppendLine("{")
+ .AppendIndent(bodyIndent).Append(model.HandlerReturnType).Append(" Handler(")
+ .Append(model.HandlerParameters).AppendLine(")")
+ .AppendIndent(bodyIndent).AppendLine("{")
+ .AppendIndent(statementIndent).Append("observer.OnNext(").Append(payloadValue)
+ .AppendLine(");");
+
+ if (model.HandlerReturnValue.Length > 0)
+ {
+ _ = builder.AppendIndent(statementIndent).Append("return ")
+ .Append(model.HandlerReturnValue).AppendLine(";");
+ }
+
+ _ = builder.AppendIndent(bodyIndent).AppendLine("}")
+ .AppendIndent(bodyIndent).Append(model.EventAccess).AppendLine(" += Handler;")
+ .AppendIndent(bodyIndent).Append("return ").Append(ProviderResolver.DisposableFactory(provider))
+ .Append("(() => ").Append(model.EventAccess).AppendLine(" -= Handler);")
+ .AppendIndent(indent).AppendLine("});")
+ .AppendLine();
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs
new file mode 100644
index 00000000..07f09c69
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/CodeGeneration/StaticEventsEmitter.cs
@@ -0,0 +1,49 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+/// Emits one namespace's static events as observables on a shared class.
+internal static class StaticEventsEmitter
+{
+ /// The room to reserve for the file's fixed scaffolding.
+ private const int ScaffoldCapacity = 256;
+
+ /// The room to reserve per generated observable property.
+ private const int PropertyCapacity = 512;
+
+ /// Emits the static observable properties for one namespace.
+ /// The namespace and its static events.
+ /// The observable implementation to write against.
+ /// The generated source.
+ ///
+ /// A static event has no instance to hang an extension method off, so it is reached through a class named the
+ /// same in every namespace. The class is partial, so a consumer can add to it and so that two requested hosts
+ /// in one namespace do not fight over the declaration.
+ ///
+ internal static string Emit(StaticNamespaceModel model, ObservableProvider provider)
+ {
+ var events = model.Events.AsArray();
+ var builder = new PooledStringBuilder(ScaffoldCapacity + (events.Length * PropertyCapacity));
+ var indent = SourceFileWriter.AppendHeader(
+ builder,
+ model.Namespace,
+ model.SupportsNullableAnnotations);
+
+ _ = builder.AppendIndent(indent).Append("internal static partial class ")
+ .AppendLine(Constants.StaticEventsClassName)
+ .AppendIndent(indent).AppendLine("{");
+
+ foreach (var eventModel in events)
+ {
+ SourceFileWriter.AppendEventProperty(builder, eventModel, provider, indent + SourceFileWriter.IndentWidth);
+ }
+
+ _ = builder.AppendIndent(indent).AppendLine("}");
+ SourceFileWriter.AppendFooter(builder, model.Namespace);
+ return builder.ToStringAndReturn();
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs b/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs
new file mode 100644
index 00000000..99d23f09
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Constants.cs
@@ -0,0 +1,105 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents;
+
+/// The names the generator matches against consumer code and writes into generated source.
+internal static class Constants
+{
+ /// The activation method name a consumer calls to request instance wrappers.
+ internal const string EventMethodName = "Events";
+
+ /// The class declaring the activation method, used to reject unrelated methods named Events.
+ internal const string ActivationExtensionsDisplayName =
+ "ReactiveUI.Primitives.ObservableEvents.ObservableGeneratorExtensions";
+
+ /// The assembly attribute that requests static event wrappers, as written in source.
+ internal const string StaticRequestAttributeName = "GenerateStaticEventObservables";
+
+ /// The same attribute written with the suffix the language lets a user leave off.
+ internal const string StaticRequestAttributeQualifiedName = "GenerateStaticEventObservablesAttribute";
+
+ /// The hint name of the activation API emitted during post-initialization.
+ internal const string ActivationHintName = "ObservableEvents.Activation.g.cs";
+
+ /// The hint name of the file carrying every generated activation overload.
+ internal const string ExtensionsHintName = "ObservableEvents.Extensions.g.cs";
+
+ /// The namespace the activation API and its generated overloads live in.
+ internal const string GeneratedNamespace = "ReactiveUI.Primitives.ObservableEvents";
+
+ /// The partial class carrying the generated activation overloads.
+ internal const string ActivationExtensionsClassName = "ObservableGeneratorExtensions";
+
+ /// The partial class carrying generated static observable properties.
+ internal const string StaticEventsClassName = "RxEvents";
+
+ /// The marker every generated file opens with.
+ internal const string GeneratedFileHeader = "// \n";
+
+ /// The directive that puts a generated file in a nullable-aware context.
+ ///
+ /// Emitted only where the consumer's language version allows it, and always together with the annotations on
+ /// the generated handler signatures: the directive is what makes those annotations mean anything, and the
+ /// annotations are what make the handlers match the delegates they are assigned to.
+ ///
+ internal const string NullableEnableDirective = "#nullable enable\n";
+
+ /// The lean signal factory host, whose presence selects the lean provider.
+ internal const string LeanSignalMetadataName = "ReactiveUI.Primitives.Signals.Signal";
+
+ /// The shared disposable scope, required by both Primitives providers.
+ internal const string ScopeMetadataName = "ReactiveUI.Primitives.Disposables.Scope";
+
+ /// The lean void payload type, required by the lean provider.
+ internal const string RxVoidMetadataName = "ReactiveUI.Primitives.RxVoid";
+
+ /// The reactive-flavoured signal factory host.
+ internal const string ReactiveSignalMetadataName = "ReactiveUI.Primitives.Reactive.Signals.Signal";
+
+ /// The System.Reactive void payload type.
+ internal const string ReactiveUnitMetadataName = "System.Reactive.Unit";
+
+ /// The System.Reactive observable factory host.
+ internal const string ReactiveObservableMetadataName = "System.Reactive.Linq.Observable";
+
+ /// The System.Reactive disposable factory host.
+ internal const string ReactiveDisposableMetadataName = "System.Reactive.Disposables.Disposable";
+
+ /// The task type an event delegate may return.
+ internal const string TaskMetadataName = "System.Threading.Tasks.Task";
+
+ /// The value task type an event delegate may return.
+ internal const string ValueTaskMetadataName = "System.Threading.Tasks.ValueTask";
+
+ /// The lean observable factory written into generated source.
+ internal const string LeanSignalCreate = "global::ReactiveUI.Primitives.Signals.Signal.Create";
+
+ /// The reactive-flavoured observable factory written into generated source.
+ internal const string ReactiveSignalCreate = "global::ReactiveUI.Primitives.Reactive.Signals.Signal.Create";
+
+ /// The System.Reactive observable factory written into generated source.
+ internal const string ObservableCreate = "global::System.Reactive.Linq.Observable.Create";
+
+ /// The Primitives disposable factory written into generated source.
+ internal const string ScopeCreate = "global::ReactiveUI.Primitives.Disposables.Scope.Create";
+
+ /// The System.Reactive disposable factory written into generated source.
+ internal const string DisposableCreate = "global::System.Reactive.Disposables.Disposable.Create";
+
+ /// The lean void payload type written into generated source.
+ internal const string RxVoidType = "global::ReactiveUI.Primitives.RxVoid";
+
+ /// The System.Reactive void payload type written into generated source.
+ internal const string ReactiveUnitType = "global::System.Reactive.Unit";
+
+ /// The singleton member of whichever void payload type the provider selects.
+ internal const string DefaultMember = ".Default";
+
+ /// The completed task returned by a task-returning generated handler.
+ internal const string CompletedTask = "global::System.Threading.Tasks.Task.CompletedTask";
+
+ /// The value returned by a value-task-returning generated handler.
+ internal const string DefaultValueTask = "default";
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/DiagnosticWarnings.cs b/src/ReactiveUI.Primitives.ObservableEvents/DiagnosticWarnings.cs
new file mode 100644
index 00000000..71e1b263
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/DiagnosticWarnings.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace ReactiveUI.Primitives.ObservableEvents;
+
+/// The diagnostics the observable-event generator reports against consumer code.
+internal static class DiagnosticWarnings
+{
+ /// The category shared by every RXOE descriptor.
+ internal const string Category = "ReactiveUI.Primitives.ObservableEvents";
+
+ /// The reason reported for an event whose type is not a delegate.
+ internal const string NotADelegateReason = "the event type is not a delegate";
+
+ /// The reason reported for a delegate taking a by-reference parameter.
+ internal const string ByReferenceParameterReason = "by-reference delegate parameters are not supported";
+
+ /// The reason reported for a delegate whose payload cannot cross a lambda boundary.
+ internal const string UnrepresentablePayloadReason =
+ "pointer, function-pointer, and ref-like payloads are not supported";
+
+ /// The reason reported for a delegate returning something other than void, Task, or ValueTask.
+ internal const string UnsupportedReturnReason = "the delegate must return void, Task, or ValueTask";
+
+ /// The reason reported for a generic static event host.
+ internal const string GenericStaticHostReason = "generic static event hosts are not supported";
+
+ /// The host kind reported by for an instance request.
+ internal const string InstanceHostKind = "instance";
+
+ /// The host kind reported by for a static request.
+ internal const string StaticHostKind = "static";
+
+ /// RXOE001: no supported observable factory is visible to the consumer.
+ internal static readonly DiagnosticDescriptor MissingProvider = new(
+ "RXOE001",
+ "Observable provider not found",
+ "Observable events for '{0}' require ReactiveUI.Primitives, ReactiveUI.Primitives.Reactive, or System.Reactive",
+ Category,
+ DiagnosticSeverity.Warning,
+ true);
+
+ /// RXOE002: a requested host declares no event this generator can expose.
+ internal static readonly DiagnosticDescriptor NoEvents = new(
+ "RXOE002",
+ "No supported events found",
+ "No supported public {0} events were found on '{1}'",
+ Category,
+ DiagnosticSeverity.Warning,
+ true);
+
+ /// RXOE003: an event's delegate signature cannot be represented as an observable.
+ internal static readonly DiagnosticDescriptor UnsupportedEvent = new(
+ "RXOE003",
+ "Event signature is not supported",
+ "Event '{0}' cannot be exposed as an observable: {1}",
+ Category,
+ DiagnosticSeverity.Warning,
+ true);
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs
index 8889acd6..6f0bfec7 100644
--- a/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs
+++ b/src/ReactiveUI.Primitives.ObservableEvents/EventGenerator.cs
@@ -3,1025 +3,237 @@
// See the LICENSE file in the project root for full license information.
using System.Collections.Immutable;
-using System.Globalization;
using System.Runtime.CompilerServices;
-using System.Text;
using Microsoft.CodeAnalysis;
-using Microsoft.CodeAnalysis.CSharp;
-using Microsoft.CodeAnalysis.CSharp.Syntax;
-using Microsoft.CodeAnalysis.Text;
+using ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+using ReactiveUI.Primitives.ObservableEvents.Helpers;
+using ReactiveUI.Primitives.ObservableEvents.Models;
namespace ReactiveUI.Primitives.ObservableEvents;
-/// Generates observable wrappers for event-bearing types requested by consumer source.
+/// Generates observable wrappers for the event-bearing types a consumer asks for.
+///
+///
+/// Two things ask for generation. An Events() call names its receiver, which is what makes the API
+/// discoverable from the call site; an assembly attribute names a static host, which has no receiver to call
+/// through. Both converge on the same extraction and the same emitter.
+///
+///
+/// Everything that leaves a semantic transform is a model of strings that compares by value, and every output is
+/// keyed on the smallest model that decides it: one wrapper per host, one file per namespace of static events, one
+/// file of activation overloads. An edit to one host's events therefore re-emits that host's file and nothing else,
+/// and an edit anywhere else re-emits nothing at all.
+///
+///
[Generator(LanguageNames.CSharp)]
public sealed class EventGenerator : IIncrementalGenerator
{
- /// The activation method name.
- private const string EventMethodName = "Events";
-
- /// The indentation used for members nested one level.
- private const int MemberIndent = 4;
-
- /// The indentation used for members nested two levels.
- private const int NestedMemberIndent = 8;
-
- /// The offset basis for deterministic FNV-1a generated-name hashes.
- private const ulong HashOffsetBasis = 14_695_981_039_346_656_037;
-
- /// The prime for deterministic FNV-1a generated-name hashes.
- private const ulong HashPrime = 1_099_511_628_211;
-
- /// Source injected before normal incremental generation begins.
- private const string SupportSource = """
- //
- #nullable enable
- namespace ReactiveUI.Primitives.ObservableEvents
- {
- /// Requests observable wrappers for public static events on a type.
- [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, AllowMultiple = true)]
- internal sealed class GenerateStaticEventObservablesAttribute : global::System.Attribute
- {
- /// Initializes the request.
- /// The static event host.
- public GenerateStaticEventObservablesAttribute(global::System.Type type) => Type = type;
-
- /// Gets the requested static event host.
- public global::System.Type Type { get; }
- }
-
- /// Contains activation extensions used by the observable-event generator.
- internal static partial class ObservableGeneratorExtensions
- {
- /// Requests observable wrappers for the receiver's public events.
- /// The event host type.
- /// The event host.
- /// A placeholder replaced by a generated, strongly typed overload.
- public static NullEvents Events(this T eventHost) => default;
- }
-
- /// Placeholder returned until a strongly typed event wrapper is generated.
- internal readonly struct NullEvents
- {
- }
- }
- """;
-
- /// Reports that no supported observable factory is visible.
- private static readonly DiagnosticDescriptor MissingProvider = new(
- "RXOE001",
- "Observable provider not found",
- "Observable events for '{0}' require ReactiveUI.Primitives, ReactiveUI.Primitives.Reactive, or System.Reactive",
- "ReactiveUI.Primitives.ObservableEvents",
- DiagnosticSeverity.Warning,
- true);
-
- /// Reports that a requested target has no supported events.
- private static readonly DiagnosticDescriptor NoEvents = new(
- "RXOE002",
- "No supported events found",
- "No supported public {0} events were found on '{1}'",
- "ReactiveUI.Primitives.ObservableEvents",
- DiagnosticSeverity.Warning,
- true);
-
- /// Reports an event signature that cannot be represented safely.
- private static readonly DiagnosticDescriptor UnsupportedEvent = new(
- "RXOE003",
- "Event signature is not supported",
- "Event '{0}' cannot be exposed as an observable: {1}",
- "ReactiveUI.Primitives.ObservableEvents",
- DiagnosticSeverity.Warning,
- true);
-
- /// The observable implementation selected from consumer references.
- private enum Provider
- {
- /// ReactiveUI.Primitives lean implementation.
- Lean = 0,
-
- /// ReactiveUI.Primitives.Reactive implementation.
- Reactive = 1,
-
- /// Standalone System.Reactive implementation.
- SystemReactive = 2,
- }
-
///
public void Initialize(IncrementalGeneratorInitializationContext context)
{
- context.RegisterPostInitializationOutput(static output =>
- output.AddSource("ObservableEvents.Activation.g.cs", SourceText.From(SupportSource, Encoding.UTF8)));
-
- var requests = context.SyntaxProvider.CreateSyntaxProvider(
- IsEventsInvocation,
- GetInstanceRequest)
- .Collect();
-
+ RegisterActivationOutput(in context);
+
+ // Which observable library is referenced decides every type name in the generated source, but nothing about
+ // which events exist. Resolving it here, into a value the pipeline can compare, keeps the far more
+ // expensive extraction from re-running when only the reference set moves.
+ var provider = context.CompilationProvider
+ .Select(static (compilation, _) => ProviderResolver.Resolve(compilation))
+ .WithTrackingName(GeneratorStepNames.Provider);
+
+ var instanceTargets = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ InstanceTargetExtractor.IsActivationInvocation,
+ InstanceTargetExtractor.Extract)
+ .Where(static target => target is not null)
+ .Select(static (target, _) => target!)
+ .Collect()
+ .SelectMany(static (targets, _) => TargetCollections.Deduplicate(targets))
+ .WithTrackingName(GeneratorStepNames.InstanceTargets);
+
+ var staticTargets = context.SyntaxProvider
+ .CreateSyntaxProvider(
+ StaticTargetExtractor.IsStaticRequestAttribute,
+ StaticTargetExtractor.Extract)
+ .Where(static target => target is not null)
+ .Select(static (target, _) => target!)
+ .Collect()
+ .SelectMany(static (targets, _) => TargetCollections.Deduplicate(targets))
+ .WithTrackingName(GeneratorStepNames.StaticTargets);
+
+ RegisterInstanceOutputs(in context, instanceTargets, provider);
+ RegisterStaticOutputs(in context, staticTargets, provider);
+ }
+
+ /// Registers the activation API a consumer writes against.
+ /// The generator initialization context.
+ ///
+ /// Deliberately an ordinary source output rather than post-initialization output, even though it depends on
+ /// nothing and could be produced before anything is scanned. Post-initialization source is added to the
+ /// compilation the pipeline then runs against, which makes that compilation new on every single run and throws
+ /// away every semantic result cached against the previous one - so a driver that has already generated
+ /// re-binds every call site from scratch, whether or not anything changed. One inert post-initialization file
+ /// is enough to cost that, so this generator emits none.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void RegisterActivationOutput(in IncrementalGeneratorInitializationContext context) =>
context.RegisterSourceOutput(
- requests.Combine(context.CompilationProvider),
- static (output, value) => Generate(output, value.Right, value.Left));
- }
+ context.ParseOptionsProvider.Select(static (_, _) => ActivationSource.Text),
+ static (output, source) => output.AddSource(Constants.ActivationHintName, source));
- /// Determines whether syntax can represent an observable-events activation call.
- /// The syntax node being considered.
- /// A token that cancels candidate inspection.
- /// when the node is a parameterless Events invocation.
- private static bool IsEventsInvocation(SyntaxNode node, CancellationToken cancellationToken)
+ /// Registers the wrapper files and the one file carrying every activation overload.
+ /// The generator initialization context.
+ /// The distinct requested hosts.
+ /// The resolved observable implementation.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void RegisterInstanceOutputs(
+ in IncrementalGeneratorInitializationContext context,
+ IncrementalValuesProvider targets,
+ IncrementalValueProvider provider)
{
- _ = cancellationToken;
- return node is InvocationExpressionSyntax invocation
- && invocation.ArgumentList.Arguments.Count == 0
- && invocation.Expression is MemberAccessExpressionSyntax memberAccess
- && memberAccess.Name.Identifier.ValueText == EventMethodName;
- }
+ context.RegisterSourceOutput(
+ targets.Combine(provider),
+ static (output, data) => EmitInstanceTarget(in output, data.Left, data.Right));
- /// Resolves a semantic instance-generation request from a candidate invocation.
- /// The semantic syntax-provider context.
- /// A token that cancels semantic inspection.
- /// The resolved request, or for an unrelated invocation.
- private static InstanceRequest? GetInstanceRequest(
- GeneratorSyntaxContext context,
- CancellationToken cancellationToken)
+ // Keyed on the overload signatures alone, so changing what a wrapper exposes leaves this file untouched.
+ context.RegisterSourceOutput(
+ targets
+ .Where(static target => !target.Events.IsEmpty)
+ .Select(static (target, _) => target.ToActivation())
+ .Collect()
+ .WithTrackingName(GeneratorStepNames.ActivationOverloads)
+ .Combine(provider),
+ static (output, data) => EmitActivationOverloads(in output, data.Left, data.Right));
+ }
+
+ /// Registers the per-namespace static wrapper files and their request diagnostics.
+ /// The generator initialization context.
+ /// The distinct requested static hosts.
+ /// The resolved observable implementation.
+ ///
+ /// Diagnostics hang off the individual requests while source hangs off the namespace groups, because a request
+ /// that produced no events still has something to say about itself but contributes nothing to a file.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void RegisterStaticOutputs(
+ in IncrementalGeneratorInitializationContext context,
+ IncrementalValuesProvider targets,
+ IncrementalValueProvider provider)
{
- var invocation = (InvocationExpressionSyntax)context.Node;
- var method = context.SemanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol as IMethodSymbol;
- if (method?.ContainingType.ToDisplayString()
- != "ReactiveUI.Primitives.ObservableEvents.ObservableGeneratorExtensions")
- {
- return null;
- }
-
- var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
- var receiverType = context.SemanticModel.GetTypeInfo(memberAccess.Expression, cancellationToken).Type as INamedTypeSymbol;
- return receiverType is null
- ? null
- : new InstanceRequest(receiverType.OriginalDefinition, invocation.GetLocation());
- }
+ context.RegisterSourceOutput(
+ targets.Combine(provider),
+ static (output, data) => ReportStaticTargetDiagnostics(in output, data.Left, data.Right));
- /// Generates all requested instance and static event adapters.
- /// The source-production context.
- /// The consumer compilation.
- /// The collected instance requests.
- private static void Generate(
- in SourceProductionContext context,
- Compilation compilation,
- ImmutableArray candidateRequests)
- {
- var provider = GetProvider(compilation);
- var requests = DeduplicateRequests(candidateRequests);
- var staticRequests = GetStaticRequests(compilation);
- if (requests.Count == 0 && staticRequests.Count == 0)
- {
+ context.RegisterSourceOutput(
+ targets
+ .Collect()
+ .SelectMany(static (collected, _) => TargetCollections.GroupByNamespace(collected))
+ .WithTrackingName(GeneratorStepNames.StaticNamespaces)
+ .Combine(provider),
+ static (output, data) => EmitStaticNamespace(in output, data.Left, data.Right));
+ }
+
+ /// Emits one host's wrapper, or says why it cannot be emitted.
+ /// The source-production context.
+ /// The requested host.
+ /// The resolved observable implementation.
+ private static void EmitInstanceTarget(
+ in SourceProductionContext output,
+ InstanceTargetModel target,
+ ObservableProvider provider)
+ {
+ if (provider == ObservableProvider.None)
+ {
+ ReportMissingProvider(in output, target.DisplayName, target.Location);
return;
}
- if (provider is null)
+ ReportDiagnostics(in output, target.Diagnostics);
+ if (target.Events.IsEmpty)
{
- ReportMissingProviders(context, requests, staticRequests);
return;
}
- var resolvedProvider = provider.Value;
-
- var extensionMethods = new StringBuilder();
- foreach (var request in requests)
- {
- GenerateInstanceTarget(context, compilation, resolvedProvider, request, extensionMethods);
- }
-
- if (extensionMethods.Length > 0)
- {
- context.AddSource(
- "ObservableEvents.Extensions.g.cs",
- SourceText.From(WrapExtensions(extensionMethods), Encoding.UTF8));
- }
-
- GenerateStaticTargets(context, compilation, resolvedProvider, staticRequests);
- }
-
- /// Selects the best observable provider visible to the consumer compilation.
- /// The consumer compilation.
- /// The selected provider, or when none is available.
- private static Provider? GetProvider(Compilation compilation)
- {
- if (compilation.GetTypeByMetadataName("ReactiveUI.Primitives.Signals.Signal") is not null
- && compilation.GetTypeByMetadataName("ReactiveUI.Primitives.Disposables.Scope") is not null
- && compilation.GetTypeByMetadataName("ReactiveUI.Primitives.RxVoid") is not null)
- {
- return Provider.Lean;
- }
-
- if (compilation.GetTypeByMetadataName("ReactiveUI.Primitives.Reactive.Signals.Signal") is not null
- && compilation.GetTypeByMetadataName("ReactiveUI.Primitives.Disposables.Scope") is not null
- && compilation.GetTypeByMetadataName("System.Reactive.Unit") is not null)
- {
- return Provider.Reactive;
- }
-
- return compilation.GetTypeByMetadataName("System.Reactive.Linq.Observable") is not null
- && compilation.GetTypeByMetadataName("System.Reactive.Disposables.Disposable") is not null
- && compilation.GetTypeByMetadataName("System.Reactive.Unit") is not null
- ? Provider.SystemReactive
- : null;
- }
-
- /// Deduplicates repeated activation calls for the same host type.
- /// The candidate activation requests.
- /// The distinct requests in source order.
- private static List DeduplicateRequests(ImmutableArray requests)
- {
- var result = new List();
- var seen = new HashSet(SymbolEqualityComparer.Default);
- foreach (var candidate in requests)
- {
- if (candidate is not null && seen.Add(candidate.Type))
- {
- result.Add(candidate);
- }
- }
-
- return result;
+ output.AddSource(target.HintName, InstanceWrapperEmitter.Emit(target, provider));
}
- /// Finds distinct assembly-level static generation requests.
- /// The consumer compilation.
- /// The distinct static requests.
- private static List GetStaticRequests(Compilation compilation)
+ /// Emits every generated activation overload.
+ /// The source-production context.
+ /// The overloads to emit.
+ /// The resolved observable implementation.
+ private static void EmitActivationOverloads(
+ in SourceProductionContext output,
+ ImmutableArray overloads,
+ ObservableProvider provider)
{
- var result = new List();
- var seen = new HashSet(SymbolEqualityComparer.Default);
- foreach (var attribute in compilation.Assembly.GetAttributes())
+ if (provider == ObservableProvider.None || overloads.IsEmpty)
{
- if (attribute.AttributeClass!.ToDisplayString()
- != "ReactiveUI.Primitives.ObservableEvents.GenerateStaticEventObservablesAttribute"
- || attribute.ConstructorArguments.Length != 1
- || attribute.ConstructorArguments[0].Value is not INamedTypeSymbol target)
- {
- continue;
- }
-
- target = target.OriginalDefinition;
- if (!seen.Add(target))
- {
- continue;
- }
-
- var location = attribute.ApplicationSyntaxReference!.GetSyntax().GetLocation();
- result.Add(new(target, location));
- }
-
- return result;
- }
-
- /// Reports a missing provider for every requested host.
- /// The source-production context.
- /// The instance requests to diagnose.
- /// The static requests to diagnose.
- private static void ReportMissingProviders(
- in SourceProductionContext context,
- IReadOnlyList requests,
- IReadOnlyList staticRequests)
- {
- foreach (var request in requests)
- {
- context.ReportDiagnostic(Diagnostic.Create(
- MissingProvider,
- request.Location,
- request.Type.ToDisplayString()));
- }
-
- foreach (var request in staticRequests)
- {
- context.ReportDiagnostic(Diagnostic.Create(
- MissingProvider,
- request.Location,
- request.Type.ToDisplayString()));
- }
- }
-
- /// Generates one strongly typed instance wrapper and activation overload.
- /// The source-production context.
- /// The consumer compilation.
- /// The selected observable provider.
- /// The instance-generation request.
- /// The shared extension-method source builder.
- private static void GenerateInstanceTarget(
- in SourceProductionContext context,
- Compilation compilation,
- Provider provider,
- InstanceRequest request,
- StringBuilder extensionMethods)
- {
- var events = GetEvents(context, compilation, request.Type, false, request.Location);
- if (events.Count == 0)
- {
- context.ReportDiagnostic(Diagnostic.Create(
- NoEvents,
- request.Location,
- "instance",
- request.Type.ToDisplayString()));
return;
}
- var wrapperName = GetWrapperName(request.Type);
- var source = GenerateInstanceWrapper(provider, request.Type, events, wrapperName);
- context.AddSource(GetHintName(request.Type, "Instance"), SourceText.From(source, Encoding.UTF8));
- AppendExtensionMethod(extensionMethods, request.Type, wrapperName);
- }
-
- /// Generates all assembly-requested static event properties.
- /// The source-production context.
- /// The consumer compilation.
- /// The selected observable provider.
- /// The static-generation requests.
- private static void GenerateStaticTargets(
- in SourceProductionContext context,
- Compilation compilation,
- Provider provider,
- IReadOnlyList requests)
- {
- var byNamespace = new Dictionary(StringComparer.Ordinal);
- foreach (var request in requests)
- {
- if (request.Type.IsGenericType)
- {
- context.ReportDiagnostic(Diagnostic.Create(
- UnsupportedEvent,
- request.Location,
- request.Type.ToDisplayString(),
- "generic static event hosts are not supported"));
- continue;
- }
-
- var events = GetEvents(context, compilation, request.Type, true, request.Location);
- if (events.Count == 0)
- {
- context.ReportDiagnostic(Diagnostic.Create(
- NoEvents,
- request.Location,
- "static",
- request.Type.ToDisplayString()));
- continue;
- }
-
- var namespaceName = request.Type.ContainingNamespace.IsGlobalNamespace
- ? string.Empty
- : request.Type.ContainingNamespace.ToDisplayString();
- if (!byNamespace.TryGetValue(namespaceName, out var members))
- {
- members = new();
- byNamespace.Add(namespaceName, members);
- }
-
- foreach (var eventSymbol in events)
- {
- AppendEventProperty(
- members,
- provider,
- eventSymbol,
- GetStaticPropertyName(request.Type, eventSymbol.Name),
- null,
- true,
- null);
- }
- }
-
- foreach (var pair in byNamespace)
- {
- context.AddSource(
- $"ObservableEvents.{GetUniqueNameComponent(pair.Key)}.Static.g.cs",
- SourceText.From(WrapStaticEvents(pair.Key, pair.Value), Encoding.UTF8));
- }
+ output.AddSource(Constants.ExtensionsHintName, ActivationExtensionsEmitter.Emit(overloads));
}
- /// Collects supported public events from a target and its base types.
- /// The source-production context.
- /// The consumer compilation.
- /// The event host type.
- /// Whether static events are requested.
- /// The request location used for diagnostics.
- /// The supported events visible on the target.
- private static List GetEvents(
- in SourceProductionContext context,
- Compilation compilation,
- INamedTypeSymbol target,
- bool isStatic,
- Location requestLocation)
+ /// Reports what a static request could not do, once a provider is known to exist.
+ /// The source-production context.
+ /// The requested static host.
+ /// The resolved observable implementation.
+ private static void ReportStaticTargetDiagnostics(
+ in SourceProductionContext output,
+ StaticTargetModel target,
+ ObservableProvider provider)
{
- var result = new List();
- var seen = new HashSet(StringComparer.Ordinal);
- for (INamedTypeSymbol? current = target; current is not null; current = current.BaseType)
+ if (provider == ObservableProvider.None)
{
- foreach (var member in current.GetMembers())
- {
- if (member is not IEventSymbol eventSymbol)
- {
- continue;
- }
-
- if (eventSymbol.IsStatic != isStatic
- || eventSymbol.DeclaredAccessibility != Accessibility.Public
- || !seen.Add(eventSymbol.Name))
- {
- continue;
- }
-
- var reason = GetUnsupportedReason(compilation, eventSymbol);
- if (reason is null)
- {
- result.Add(eventSymbol);
- }
- else
- {
- context.ReportDiagnostic(Diagnostic.Create(
- UnsupportedEvent,
- requestLocation,
- eventSymbol.ToDisplayString(),
- reason));
- }
- }
- }
-
- return result;
- }
-
- /// Returns the reason an event cannot be generated, or null when it is supported.
- /// The consumer compilation.
- /// The event to validate.
- /// The unsupported reason, or when the event is supported.
- private static string? GetUnsupportedReason(Compilation compilation, IEventSymbol eventSymbol)
- {
- if (eventSymbol.Type is not INamedTypeSymbol { DelegateInvokeMethod: { } invokeMethod })
- {
- return "the event type is not a delegate";
- }
-
- foreach (var parameter in invokeMethod.Parameters)
- {
- if (parameter.RefKind != RefKind.None)
- {
- return "by-reference delegate parameters are not supported";
- }
-
- if (parameter.Type.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer || parameter.Type.IsRefLikeType)
- {
- return "pointer, function-pointer, and ref-like payloads are not supported";
- }
- }
-
- if (invokeMethod.ReturnsVoid)
- {
- return null;
- }
-
- var task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task");
- var valueTask = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask");
- return SymbolEqualityComparer.Default.Equals(invokeMethod.ReturnType, task)
- || SymbolEqualityComparer.Default.Equals(invokeMethod.ReturnType, valueTask)
- ? null
- : "the delegate must return void, Task, or ValueTask";
- }
-
- /// Builds source for one instance event wrapper.
- /// The selected observable provider.
- /// The event host type.
- /// The supported events to expose.
- /// The generated wrapper class name.
- /// The generated instance-wrapper source.
- private static string GenerateInstanceWrapper(
- Provider provider,
- INamedTypeSymbol type,
- IReadOnlyList events,
- string wrapperName)
- {
- var typeParameterNames = GetTypeParameterNames(type);
- var members = new StringBuilder();
- var typeReference = Display(type, typeParameterNames);
- _ = members.Append(" private readonly ").Append(typeReference).AppendLine(" _host;")
- .AppendLine()
- .Append(" internal ").Append(wrapperName).Append('(')
- .Append(typeReference).AppendLine(" host) => _host = host;")
- .AppendLine();
- foreach (var eventSymbol in events)
- {
- AppendEventProperty(
- members,
- provider,
- eventSymbol,
- eventSymbol.Name,
- "_host",
- false,
- typeParameterNames);
- }
-
- var declaration = new StringBuilder()
- .Append("internal sealed class ").Append(wrapperName)
- .Append(GetTypeParameterList(type, typeParameterNames)).AppendLine()
- .Append(GetConstraints(type, typeParameterNames))
- .AppendLine("{")
- .Append(members)
- .AppendLine("}")
- .ToString();
- return WrapNamespace(type.ContainingNamespace, declaration);
- }
-
- /// Appends the strongly typed Events extension for a host.
- /// The shared extension-method source builder.
- /// The event host type.
- /// The generated wrapper class name.
- private static void AppendExtensionMethod(StringBuilder builder, INamedTypeSymbol type, string wrapperName)
- {
- var typeParameterNames = GetTypeParameterNames(type);
- var typeParameters = GetTypeParameterList(type, typeParameterNames);
- var wrapperReference = GetNamespacePrefix(type.ContainingNamespace) + wrapperName + typeParameters;
- _ = builder.Append(" /// Gets observable wrappers for public events on .")
- .Append(" public static ").Append(wrapperReference).Append(" Events")
- .Append(typeParameters).Append("(this ").Append(Display(type, typeParameterNames)).AppendLine(" eventHost)")
- .Append(Indent(GetConstraints(type, typeParameterNames), NestedMemberIndent))
- .Append(" => new ").Append(wrapperReference).AppendLine("(eventHost);")
- .AppendLine();
- }
-
- /// Appends an observable property for an event.
- /// The destination source builder.
- /// The selected observable provider.
- /// The event to expose.
- /// The generated property name.
- /// The instance-host expression, or for static events.
- /// Whether the generated property and event subscription are static.
- /// The generated type-parameter names for an instance wrapper.
- private static void AppendEventProperty(
- StringBuilder builder,
- Provider provider,
- IEventSymbol eventSymbol,
- string propertyName,
- string? hostField,
- bool isStatic,
- IReadOnlyDictionary? typeParameterNames)
- {
- var invokeMethod = ((INamedTypeSymbol)eventSymbol.Type).DelegateInvokeMethod!;
- var payloadType = GetPayloadType(provider, invokeMethod, typeParameterNames);
- var eventTarget = isStatic
- ? Display(eventSymbol.ContainingType)
- : $"(({Display(eventSymbol.ContainingType, typeParameterNames)}){hostField})";
- var handlerParameters = JoinParameters(invokeMethod.Parameters, true, typeParameterNames);
- var payloadValue = GetPayloadValue(provider, invokeMethod);
- var returnPrefix = invokeMethod.ReturnsVoid ? string.Empty : "return ";
- var returnValue = GetReturnValue(invokeMethod);
-
- _ = builder.Append(" /// Gets an observable that signals when the ")
- .Append(EscapeXml(eventSymbol.Name)).AppendLine(" event is raised.")
- .Append(" public ").Append(isStatic ? "static " : string.Empty)
- .Append("global::System.IObservable<").Append(payloadType).Append("> ")
- .Append(EscapeIdentifier(propertyName)).Append(" => ").Append(GetFactory(provider))
- .Append('<').Append(payloadType).AppendLine(">(observer =>")
- .AppendLine(" {")
- .Append(" ").Append(Display(invokeMethod.ReturnType, typeParameterNames)).Append(" Handler(")
- .Append(handlerParameters).AppendLine(")")
- .AppendLine(" {")
- .Append(" observer.OnNext(").Append(payloadValue).AppendLine(");");
- if (!invokeMethod.ReturnsVoid)
- {
- _ = builder.Append(" ").Append(returnPrefix).Append(returnValue).AppendLine(";");
- }
-
- _ = builder.AppendLine(" }")
- .Append(" ").Append(eventTarget).Append('.').Append(EscapeIdentifier(eventSymbol.Name))
- .AppendLine(" += Handler;")
- .Append(" return ").Append(GetDisposableFactory(provider)).Append("(() => ")
- .Append(eventTarget).Append('.').Append(EscapeIdentifier(eventSymbol.Name)).AppendLine(" -= Handler);")
- .AppendLine(" });")
- .AppendLine();
- }
-
- /// Gets the observable payload type for a delegate.
- /// The selected observable provider.
- /// The delegate invocation method.
- /// The generated type-parameter names.
- /// The fully qualified payload type source.
- private static string GetPayloadType(
- Provider provider,
- IMethodSymbol invokeMethod,
- IReadOnlyDictionary? typeParameterNames)
- {
- if (invokeMethod.Parameters.IsEmpty)
- {
- return provider == Provider.Lean
- ? "global::ReactiveUI.Primitives.RxVoid"
- : "global::System.Reactive.Unit";
- }
-
- if (invokeMethod.Parameters.Length == 1)
- {
- return Display(invokeMethod.Parameters[0].Type, typeParameterNames);
- }
-
- return invokeMethod.Parameters.Length == 2
- && invokeMethod.Parameters[0].Type.SpecialType == SpecialType.System_Object
- ? Display(invokeMethod.Parameters[1].Type, typeParameterNames)
- : $"({JoinParameters(invokeMethod.Parameters, true, typeParameterNames)})";
- }
-
- /// Gets the observer value expression for a delegate invocation.
- /// The selected observable provider.
- /// The delegate invocation method.
- /// The payload value expression.
- private static string GetPayloadValue(Provider provider, IMethodSymbol invokeMethod)
- {
- if (invokeMethod.Parameters.IsEmpty)
- {
- return provider == Provider.Lean
- ? "global::ReactiveUI.Primitives.RxVoid.Default"
- : "global::System.Reactive.Unit.Default";
- }
-
- if (invokeMethod.Parameters.Length == 1)
- {
- return EscapeIdentifier(invokeMethod.Parameters[0].Name);
- }
-
- return invokeMethod.Parameters.Length == 2
- && invokeMethod.Parameters[0].Type.SpecialType == SpecialType.System_Object
- ? EscapeIdentifier(invokeMethod.Parameters[1].Name)
- : $"({JoinParameters(invokeMethod.Parameters, false, null)})";
- }
-
- /// Gets the return expression for Task and ValueTask event handlers.
- /// The delegate invocation method.
- /// The completed return expression.
- private static string GetReturnValue(IMethodSymbol invokeMethod) =>
- invokeMethod.ReturnType.ToDisplayString() == "System.Threading.Tasks.Task"
- ? "global::System.Threading.Tasks.Task.CompletedTask"
- : "default";
-
- /// Formats one delegate handler parameter.
- /// The delegate parameter.
- /// The generated type-parameter names.
- /// The parameter declaration source.
- private static string FormatParameter(
- IParameterSymbol parameter,
- IReadOnlyDictionary? typeParameterNames) =>
- $"{Display(parameter.Type, typeParameterNames)} {EscapeIdentifier(parameter.Name)}";
-
- /// Joins delegate parameter declarations or value expressions.
- /// The delegate parameters.
- /// Whether each item includes its fully qualified type.
- /// The generated type-parameter names.
- /// The comma-separated parameter source.
- private static string JoinParameters(
- ImmutableArray parameters,
- bool includeTypes,
- IReadOnlyDictionary? typeParameterNames)
- {
- var builder = new StringBuilder();
- for (var index = 0; index < parameters.Length; index++)
- {
- if (index > 0)
- {
- _ = builder.Append(", ");
- }
-
- _ = builder.Append(includeTypes
- ? FormatParameter(parameters[index], typeParameterNames)
- : EscapeIdentifier(parameters[index].Name));
- }
-
- return builder.ToString();
- }
-
- /// Gets the selected observable creation method.
- /// The selected observable provider.
- /// The fully qualified observable factory method.
- private static string GetFactory(Provider provider) => provider switch
- {
- Provider.Lean => "global::ReactiveUI.Primitives.Signals.Signal.Create",
- Provider.Reactive => "global::ReactiveUI.Primitives.Reactive.Signals.Signal.Create",
- _ => "global::System.Reactive.Linq.Observable.Create",
- };
-
- /// Gets the selected disposable creation method.
- /// The selected observable provider.
- /// The fully qualified disposable factory method.
- private static string GetDisposableFactory(Provider provider) =>
- provider == Provider.SystemReactive
- ? "global::System.Reactive.Disposables.Disposable.Create"
- : "global::ReactiveUI.Primitives.Disposables.Scope.Create";
-
- /// Wraps generated instance activation overloads in their shared extension class.
- /// The generated activation overloads.
- /// The complete extension-class source.
- private static string WrapExtensions(StringBuilder methods) => $$"""
- //
- #nullable enable
- namespace ReactiveUI.Primitives.ObservableEvents
- {
- internal static partial class ObservableGeneratorExtensions
- {
- {{methods}} }
- }
- """;
-
- /// Wraps generated static properties in the requested namespace.
- /// The target namespace, or an empty string for the global namespace.
- /// The generated static observable properties.
- /// The complete static-wrapper source.
- private static string WrapStaticEvents(string namespaceName, StringBuilder members)
- {
- var declaration = new StringBuilder()
- .AppendLine("internal static partial class RxEvents")
- .AppendLine("{")
- .Append(members)
- .AppendLine("}")
- .ToString();
- return namespaceName.Length == 0
- ? $"// \n#nullable enable\n{declaration}"
- : $"// \n#nullable enable\nnamespace {namespaceName}\n{{\n"
- + $"{Indent(declaration, MemberIndent)}}}\n";
- }
-
- /// Wraps a declaration in a namespace, accounting for the global namespace.
- /// The target namespace.
- /// The declaration source to wrap.
- /// The complete generated source.
- private static string WrapNamespace(INamespaceSymbol namespaceSymbol, string declaration) =>
- namespaceSymbol.IsGlobalNamespace
- ? $"// \n#nullable enable\n{declaration}"
- : $"// \n#nullable enable\nnamespace {namespaceSymbol.ToDisplayString()}\n{{\n"
- + $"{Indent(declaration, MemberIndent)}}}\n";
-
- /// Gets all containing and target type parameters as a declaration list.
- /// The target type.
- /// The generated type-parameter names.
- /// The type-parameter declaration list, or an empty string for a non-generic type.
- private static string GetTypeParameterList(
- INamedTypeSymbol type,
- Dictionary typeParameterNames)
- {
- var parameters = GetAllTypeParameters(type);
- if (parameters.Count == 0)
- {
- return string.Empty;
- }
-
- var names = new string[parameters.Count];
- for (var index = 0; index < parameters.Count; index++)
- {
- names[index] = typeParameterNames[parameters[index]];
- }
-
- return $"<{string.Join(", ", names)}>";
- }
-
- /// Gets generic constraints for all containing and target type parameters.
- /// The target type.
- /// The generated type-parameter names.
- /// The generic constraint clauses.
- private static string GetConstraints(
- INamedTypeSymbol type,
- Dictionary typeParameterNames)
- {
- var builder = new StringBuilder();
- foreach (var parameter in GetAllTypeParameters(type))
- {
- var constraints = new List();
- if (parameter.HasUnmanagedTypeConstraint)
- {
- constraints.Add("unmanaged");
- }
- else if (parameter.HasValueTypeConstraint)
- {
- constraints.Add("struct");
- }
- else if (parameter.HasReferenceTypeConstraint)
- {
- constraints.Add(parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
- ? "class?"
- : "class");
- }
- else if (parameter.HasNotNullConstraint)
- {
- constraints.Add("notnull");
- }
-
- foreach (var constraintType in parameter.ConstraintTypes)
- {
- constraints.Add(Display(constraintType, typeParameterNames));
- }
-
- if (parameter.HasConstructorConstraint)
- {
- constraints.Add("new()");
- }
-
- if (constraints.Count > 0)
- {
- _ = builder.Append(" where ").Append(typeParameterNames[parameter]).Append(" : ")
- .AppendLine(string.Join(", ", constraints));
- }
- }
-
- return builder.ToString();
- }
-
- /// Collects containing and target type parameters in declaration order.
- /// The target type.
- /// The complete ordered type-parameter list.
- private static List GetAllTypeParameters(INamedTypeSymbol type)
- {
- var types = new Stack();
- for (var current = type; current is not null; current = current.ContainingType)
- {
- types.Push(current);
- }
-
- var result = new List();
- while (types.Count > 0)
- {
- result.AddRange(types.Pop().TypeParameters);
- }
-
- return result;
- }
-
- /// Creates unique generated identifiers for containing and target type parameters.
- /// The target type.
- /// The symbol-to-generated-name mapping.
- private static Dictionary GetTypeParameterNames(INamedTypeSymbol type)
- {
- var result = new Dictionary(SymbolEqualityComparer.Default);
- var usedNames = new HashSet(StringComparer.Ordinal);
- foreach (var parameter in GetAllTypeParameters(type))
- {
- var baseName = parameter.Name;
- var name = baseName;
- var suffix = 1;
- while (!usedNames.Add(name))
- {
- suffix++;
- name = baseName + suffix.ToString(CultureInfo.InvariantCulture);
- }
-
- result.Add(parameter, EscapeIdentifier(name));
- }
-
- return result;
- }
-
- /// Gets a collision-free static observable property name.
- /// The static event host.
- /// The event name.
- /// The generated property name.
- private static string GetStaticPropertyName(INamedTypeSymbol type, string eventName)
- {
- var types = new Stack();
- for (var current = type; current is not null; current = current.ContainingType)
- {
- types.Push(current);
- }
-
- var builder = new StringBuilder("T");
- while (types.Count > 0)
- {
- var name = types.Pop().Name;
- _ = builder.Append(name.Length.ToString(CultureInfo.InvariantCulture)).Append(name);
- }
-
- return builder.Append(eventName.Length.ToString(CultureInfo.InvariantCulture)).Append(eventName).ToString();
- }
-
- /// Gets a collision-resistant wrapper class name.
- /// The target type.
- /// The generated wrapper class name.
- private static string GetWrapperName(INamedTypeSymbol type) =>
- $"Rx{GetUniqueNameComponent(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))}Events";
-
- /// Gets a deterministic generated-source hint name.
- /// The target type.
- /// The generated-source category suffix.
- /// The deterministic hint name.
- private static string GetHintName(INamedTypeSymbol type, string suffix) =>
- $"ObservableEvents.{GetUniqueNameComponent(type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat))}.{suffix}.g.cs";
-
- /// Gets a fully qualified namespace prefix.
- /// The namespace symbol.
- /// The global namespace prefix.
- private static string GetNamespacePrefix(INamespaceSymbol namespaceSymbol) => namespaceSymbol.IsGlobalNamespace
- ? "global::"
- : $"global::{namespaceSymbol.ToDisplayString()}.";
-
- /// Formats a symbol as a fully qualified C# type.
- /// The type symbol.
- /// The generated type-parameter names.
- /// The fully qualified type source.
- private static string Display(
- ITypeSymbol symbol,
- IReadOnlyDictionary? typeParameterNames = null)
- {
- if (typeParameterNames is null || typeParameterNames.Count == 0)
- {
- return symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
- }
-
- var builder = new StringBuilder();
- foreach (var part in symbol.ToDisplayParts(SymbolDisplayFormat.FullyQualifiedFormat))
- {
- _ = builder.Append(
- part.Symbol is ITypeParameterSymbol parameter
- && typeParameterNames.TryGetValue(parameter, out var replacement)
- ? replacement
- : part.ToString());
- }
-
- return builder.ToString();
- }
-
- /// Escapes C# keyword identifiers.
- /// The identifier text.
- /// The escaped identifier.
- private static string EscapeIdentifier(string value) => SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None
- ? value
- : $"@{value}";
-
- /// Converts a symbol display into a safe class or hint-name component.
- /// The symbol display text.
- /// The sanitized identifier component.
- private static string Sanitize(string value)
- {
- var builder = new StringBuilder(value.Length);
- foreach (var character in value)
- {
- _ = builder.Append(char.IsLetterOrDigit(character) ? character : '_');
+ ReportMissingProvider(in output, target.DisplayName, target.Location);
+ return;
}
- return builder.ToString().Trim('_');
+ ReportDiagnostics(in output, target.Diagnostics);
}
- /// Creates a readable generated-name component with a stable collision-resistant suffix.
- /// The original symbol or namespace identity.
- /// The unique generated-name component.
- private static string GetUniqueNameComponent(string value) =>
- $"{Sanitize(value)}_{GetStableHash(value)}";
-
- /// Computes a deterministic FNV-1a hash for generated names.
- /// The identity to hash.
- /// The invariant uppercase hexadecimal hash.
- private static string GetStableHash(string value)
+ /// Emits one namespace's static observable properties.
+ /// The source-production context.
+ /// The namespace and its static events.
+ /// The resolved observable implementation.
+ private static void EmitStaticNamespace(
+ in SourceProductionContext output,
+ StaticNamespaceModel model,
+ ObservableProvider provider)
{
- var hash = HashOffsetBasis;
- foreach (var character in value)
+ if (provider == ObservableProvider.None)
{
- hash ^= character;
- hash *= HashPrime;
+ return;
}
- return hash.ToString("X16", CultureInfo.InvariantCulture);
+ output.AddSource(model.HintName, StaticEventsEmitter.Emit(model, provider));
}
- /// Escapes text inserted into generated XML documentation.
- /// The documentation text.
- /// The XML-escaped text.
+ /// Reports that nothing can be generated for a request because no provider is referenced.
+ /// The source-production context.
+ /// The requested host's readable name.
+ /// Where the request was written.
+ ///
+ /// Reported instead of, not alongside, whatever else extraction found: without a provider nothing would compile
+ /// anyway, and the one actionable thing to say is which package to reference.
+ ///
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- private static string EscapeXml(string value) => value.Replace("&", "&").Replace("<", "<")
- .Replace(">", ">").Replace("\"", """).Replace("'", "'");
+ private static void ReportMissingProvider(
+ in SourceProductionContext output,
+ string displayName,
+ LocationInfo? location) =>
+ output.ReportDiagnostic(
+ DiagnosticInfo.Create(DiagnosticWarnings.MissingProvider, location, displayName).ToDiagnostic());
- /// Indents every line in a generated source fragment.
- /// The source fragment.
- /// The number of leading spaces.
- /// The indented source fragment.
- private static string Indent(string value, int spaces)
+ /// Reports every diagnostic a model carried out of extraction.
+ /// The source-production context.
+ /// The diagnostics to report.
+ private static void ReportDiagnostics(
+ in SourceProductionContext output,
+ EquatableArray diagnostics)
{
- if (value.Length == 0)
+ foreach (var diagnostic in diagnostics.AsArray())
{
- return value;
+ output.ReportDiagnostic(diagnostic.ToDiagnostic());
}
-
- var indent = new string(' ', spaces);
- return $"{indent}{value.Replace("\n", $"\n{indent}").TrimEnd()}\n";
- }
-
- /// An instance wrapper request and its diagnostic location.
- /// The requested host type.
- /// The request location.
- private sealed class InstanceRequest(INamedTypeSymbol type, Location location)
- {
- /// Gets the requested host type.
- public INamedTypeSymbol Type { get; } = type;
-
- /// Gets the activation call location.
- public Location Location { get; } = location;
- }
-
- /// A static wrapper request and its diagnostic location.
- /// The requested host type.
- /// The request location.
- private sealed class StaticRequest(INamedTypeSymbol type, Location location)
- {
- /// Gets the requested static host type.
- public INamedTypeSymbol Type { get; } = type;
-
- /// Gets the assembly attribute location.
- public Location Location { get; } = location;
}
}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs
new file mode 100644
index 00000000..8cedc9b1
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/GeneratorStepNames.cs
@@ -0,0 +1,30 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents;
+
+/// The names the pipeline's steps are tracked under.
+///
+/// Tracking is what makes the caching testable: a driver told to track steps records, for every run, whether each
+/// step recomputed its value and whether that value differed from last time. Without names on the steps a
+/// regression that quietly reintroduces a symbol into a model - and so defeats the caching entirely - still
+/// produces correct output and would go unnoticed.
+///
+internal static class GeneratorStepNames
+{
+ /// The step that resolves which observable implementation is referenced.
+ internal const string Provider = "ObservableEvents.Provider";
+
+ /// The step that yields the distinct hosts requested through an Events() call.
+ internal const string InstanceTargets = "ObservableEvents.InstanceTargets";
+
+ /// The step that yields the distinct hosts requested through an assembly attribute.
+ internal const string StaticTargets = "ObservableEvents.StaticTargets";
+
+ /// The step that yields the generated activation overloads.
+ internal const string ActivationOverloads = "ObservableEvents.ActivationOverloads";
+
+ /// The step that yields the static events grouped by namespace.
+ internal const string StaticNamespaces = "ObservableEvents.StaticNamespaces";
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs
new file mode 100644
index 00000000..12ed97ec
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventExtractor.cs
@@ -0,0 +1,279 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Turns a host's events into the value-only models the emitter works from.
+internal static class EventExtractor
+{
+ /// The parameter count of a delegate whose single parameter is the whole payload.
+ private const int SingleParameterCount = 1;
+
+ /// The parameter count of the conventional sender-and-arguments event delegate.
+ private const int SenderAndArgsParameterCount = 2;
+
+ /// Collects the events a host can expose, recording a diagnostic for each one it cannot.
+ /// Everything about the host and where it was requested from.
+ /// The destination for anything found wrong.
+ /// A token that cancels the walk.
+ /// The supported events, in declaration order from the host down to its last base type.
+ ///
+ /// Walking the base chain by hand rather than asking for all members at once is what lets a derived host expose
+ /// an event it inherits. A name already seen is skipped so an event redeclared in a derived type wins over the
+ /// one it hides, which is the member a consumer's own code would bind to.
+ ///
+ internal static EquatableArray Collect(
+ in EventRequest request,
+ List diagnostics,
+ CancellationToken cancellationToken)
+ {
+ var events = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ for (INamedTypeSymbol? current = request.Host; current is not null; current = current.BaseType)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ foreach (var member in current.GetMembers())
+ {
+ if (member is not IEventSymbol eventSymbol
+ || eventSymbol.IsStatic != request.IsStatic
+ || eventSymbol.DeclaredAccessibility != Accessibility.Public
+ || !seen.Add(eventSymbol.Name))
+ {
+ continue;
+ }
+
+ var reason = SelectUnsupportedReason(eventSymbol, request.WellKnownTypes);
+ if (reason is null)
+ {
+ events.Add(CreateModel(eventSymbol, request));
+ }
+ else
+ {
+ diagnostics.Add(new(
+ DiagnosticWarnings.UnsupportedEvent,
+ request.Location,
+ eventSymbol.ToDisplayString(),
+ reason));
+ }
+ }
+ }
+
+ return events.Count == 0 ? EquatableArray.Empty : new([.. events]);
+ }
+
+ /// Builds the mangled property name a static event gets on its namespace's shared class.
+ /// The requested static host.
+ /// The event name.
+ /// The generated property name.
+ ///
+ /// Every namespace's static events share one class, so the host's name has to be part of the property name.
+ /// Concatenating names would let distinct hosts collide - A.BC and AB.C both flattening to
+ /// ABC - so each segment is length-prefixed, which no pair of different segmentations can produce.
+ ///
+ private static string StaticPropertyName(INamedTypeSymbol host, string eventName)
+ {
+ var containers = new Stack();
+ for (INamedTypeSymbol? current = host; current is not null; current = current.ContainingType)
+ {
+ containers.Push(current);
+ }
+
+ var builder = new PooledStringBuilder();
+ _ = builder.Append('T');
+ while (containers.Count > 0)
+ {
+ var name = containers.Pop().Name;
+ _ = builder.Append(name.Length).Append(name);
+ }
+
+ return builder.Append(eventName.Length).Append(eventName).ToStringAndReturn();
+ }
+
+ /// Builds the model for one supported event.
+ /// The event to expose.
+ /// The host request the event was reached through.
+ /// The event model.
+ private static EventModel CreateModel(IEventSymbol eventSymbol, in EventRequest request)
+ {
+ var invokeMethod = ((INamedTypeSymbol)eventSymbol.Type).DelegateInvokeMethod!;
+ var (payloadType, payloadValue) = SelectPayload(invokeMethod, in request);
+
+ return new(
+ request.IsStatic
+ ? StaticPropertyName(request.Host, eventSymbol.Name)
+ : SymbolHelpers.EscapeIdentifier(eventSymbol.Name),
+ BuildEventAccess(eventSymbol, request),
+ payloadType,
+ payloadValue,
+ JoinParameterDeclarations(invokeMethod, in request),
+ SymbolHelpers.Display(invokeMethod.ReturnType, request.TypeParameterNames, request.SupportsNullableAnnotations),
+ SelectHandlerReturnValue(invokeMethod, request.WellKnownTypes),
+ request.IsStatic,
+ SymbolHelpers.EscapeXml(eventSymbol.Name));
+ }
+
+ /// Builds the expression the generated handler is added to and removed from.
+ /// The event to subscribe to.
+ /// The host request the event was reached through.
+ /// The subscription target expression.
+ ///
+ /// An instance target is cast to the type that declares the event rather than to the requested host, so an
+ /// event the host inherits and hides with a member of the same name still binds to the one being wrapped.
+ ///
+ private static string BuildEventAccess(IEventSymbol eventSymbol, in EventRequest request)
+ {
+ var builder = new PooledStringBuilder();
+ if (request.IsStatic)
+ {
+ _ = builder.Append(SymbolHelpers.Display(eventSymbol.ContainingType, null, request.SupportsNullableAnnotations));
+ }
+ else
+ {
+ _ = builder.Append("((")
+ .Append(SymbolHelpers.Display(eventSymbol.ContainingType, request.TypeParameterNames, request.SupportsNullableAnnotations))
+ .Append(")_host)");
+ }
+
+ return builder.Append('.').Append(SymbolHelpers.EscapeIdentifier(eventSymbol.Name)).ToStringAndReturn();
+ }
+
+ /// Selects the observable payload type and the value handed to the observer.
+ /// The delegate's invocation method.
+ /// The host request the event was reached through.
+ /// The payload type and value, both empty for a parameterless delegate.
+ ///
+ /// The two-parameter sender/args shape is the one nearly every .NET event has, and its sender is the object the
+ /// consumer already holds, so only the args are surfaced. Anything else is passed through whole: a single
+ /// parameter as itself, several as a tuple.
+ ///
+ private static (string PayloadType, string PayloadValue) SelectPayload(
+ IMethodSymbol invokeMethod,
+ in EventRequest request)
+ {
+ var parameters = invokeMethod.Parameters;
+ if (parameters.IsEmpty)
+ {
+ return (string.Empty, string.Empty);
+ }
+
+ if (parameters.Length == SingleParameterCount)
+ {
+ return (
+ SymbolHelpers.Display(parameters[0].Type, request.TypeParameterNames, request.SupportsNullableAnnotations),
+ SymbolHelpers.EscapeIdentifier(parameters[0].Name));
+ }
+
+ return parameters.Length == SenderAndArgsParameterCount
+ && parameters[0].Type.SpecialType == SpecialType.System_Object
+ ? (SymbolHelpers.Display(parameters[1].Type, request.TypeParameterNames, request.SupportsNullableAnnotations),
+ SymbolHelpers.EscapeIdentifier(parameters[1].Name))
+ : ($"({JoinParameterDeclarations(invokeMethod, in request)})",
+ $"({JoinParameterNames(invokeMethod)})");
+ }
+
+ /// Renders the generated handler's parameter list.
+ /// The delegate's invocation method.
+ /// The host request the event was reached through.
+ /// The comma-separated parameter declarations.
+ ///
+ /// The declared types carry whatever annotations the delegate declared, because a handler is only assignable to
+ /// a delegate whose parameter nullability it matches.
+ ///
+ private static string JoinParameterDeclarations(IMethodSymbol invokeMethod, in EventRequest request)
+ {
+ var builder = new PooledStringBuilder();
+ var parameters = invokeMethod.Parameters;
+ for (var index = 0; index < parameters.Length; index++)
+ {
+ if (index > 0)
+ {
+ _ = builder.Append(", ");
+ }
+
+ _ = builder
+ .Append(SymbolHelpers.Display(parameters[index].Type, request.TypeParameterNames, request.SupportsNullableAnnotations))
+ .Append(' ')
+ .Append(SymbolHelpers.EscapeIdentifier(parameters[index].Name));
+ }
+
+ return builder.ToStringAndReturn();
+ }
+
+ /// Renders the generated handler's parameters as a value list.
+ /// The delegate's invocation method.
+ /// The comma-separated parameter names.
+ private static string JoinParameterNames(IMethodSymbol invokeMethod)
+ {
+ var builder = new PooledStringBuilder();
+ var parameters = invokeMethod.Parameters;
+ for (var index = 0; index < parameters.Length; index++)
+ {
+ if (index > 0)
+ {
+ _ = builder.Append(", ");
+ }
+
+ _ = builder.Append(SymbolHelpers.EscapeIdentifier(parameters[index].Name));
+ }
+
+ return builder.ToStringAndReturn();
+ }
+
+ /// Selects what a non-void generated handler returns.
+ /// The delegate's invocation method.
+ /// The task types resolved from the consumer compilation.
+ /// The return expression, or an empty string for a void handler.
+ private static string SelectHandlerReturnValue(IMethodSymbol invokeMethod, WellKnownTypes wellKnownTypes)
+ {
+ if (invokeMethod.ReturnsVoid)
+ {
+ return string.Empty;
+ }
+
+ var returnsTask = SymbolEqualityComparer.Default.Equals(invokeMethod.ReturnType, wellKnownTypes.Task);
+ return returnsTask ? Constants.CompletedTask : Constants.DefaultValueTask;
+ }
+
+ /// Determines why an event cannot be exposed as an observable.
+ /// The event to validate.
+ /// The task types resolved from the consumer compilation.
+ /// The reason, or when the event is supported.
+ ///
+ /// The generated handler hands its parameters to an observer, which outlives the callback, so anything that
+ /// cannot leave the stack or be captured has to be refused here rather than emitted and left to fail the
+ /// consumer's build.
+ ///
+ private static string? SelectUnsupportedReason(IEventSymbol eventSymbol, WellKnownTypes wellKnownTypes)
+ {
+ if (eventSymbol.Type is not INamedTypeSymbol { DelegateInvokeMethod: { } invokeMethod })
+ {
+ return DiagnosticWarnings.NotADelegateReason;
+ }
+
+ foreach (var parameter in invokeMethod.Parameters)
+ {
+ if (parameter.RefKind != RefKind.None)
+ {
+ return DiagnosticWarnings.ByReferenceParameterReason;
+ }
+
+ if (parameter.Type.TypeKind is TypeKind.Pointer or TypeKind.FunctionPointer
+ || parameter.Type.IsRefLikeType)
+ {
+ return DiagnosticWarnings.UnrepresentablePayloadReason;
+ }
+ }
+
+ return invokeMethod.ReturnsVoid
+ || SymbolEqualityComparer.Default.Equals(invokeMethod.ReturnType, wellKnownTypes.Task)
+ || SymbolEqualityComparer.Default.Equals(invokeMethod.ReturnType, wellKnownTypes.ValueTask)
+ ? null
+ : DiagnosticWarnings.UnsupportedReturnReason;
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs
new file mode 100644
index 00000000..9669426f
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/EventRequest.cs
@@ -0,0 +1,27 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// One host to extract events from, with everything the extraction needs to render them.
+/// The type whose events are being wrapped.
+/// Whether static rather than instance events are wanted.
+/// The generated type-parameter names, or null for a non-generic host.
+/// Whether the consumer's language can express an annotation.
+/// Where the request was written, for diagnostics.
+/// The task types resolved from the consumer compilation.
+///
+/// This carries symbols and so never leaves the semantic transform that created it; what comes back out is a model
+/// of strings. Bundling the arguments keeps the extraction methods from growing a parameter list each.
+///
+internal readonly record struct EventRequest(
+ INamedTypeSymbol Host,
+ bool IsStatic,
+ IReadOnlyDictionary? TypeParameterNames,
+ bool SupportsNullableAnnotations,
+ LocationInfo? Location,
+ WellKnownTypes WellKnownTypes);
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs
new file mode 100644
index 00000000..99ec4e9f
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/InstanceTargetExtractor.cs
@@ -0,0 +1,147 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Turns an Events() call site into the model of the wrapper it asks for.
+internal static class InstanceTargetExtractor
+{
+ /// Cheaply rejects syntax that cannot be an activation call.
+ /// The node under consideration.
+ /// A token that cancels the check.
+ /// when the node is a parameterless Events() member invocation.
+ ///
+ /// This runs on every node of every edited file, so it only looks at shape and spelling. Deciding whether the
+ /// call is really ours needs the semantic model, and is left to the transform that runs on the survivors.
+ ///
+ internal static bool IsActivationInvocation(SyntaxNode node, CancellationToken cancellationToken)
+ {
+ _ = cancellationToken;
+ return node is InvocationExpressionSyntax
+ {
+ ArgumentList.Arguments.Count: 0,
+ Expression: MemberAccessExpressionSyntax memberAccess,
+ }
+ && memberAccess.Name.Identifier.ValueText == Constants.EventMethodName;
+ }
+
+ /// Resolves an activation call into the host it wraps.
+ /// The semantic context for the candidate call.
+ /// A token that cancels the resolution.
+ /// The requested host, or for an unrelated call.
+ ///
+ /// The activation placeholder this call will eventually bind to is this generator's own output, and output is
+ /// not visible to the pipeline that produced it - so during a run the call resolves to nothing. That absence is
+ /// the signal: a call that does resolve belongs to somebody else and is left alone, and a call that
+ /// does not is ours to answer. What the request needs is the receiver's type, which binds on its own.
+ ///
+ internal static InstanceTargetModel? Extract(GeneratorSyntaxContext context, CancellationToken cancellationToken)
+ {
+ var invocation = (InvocationExpressionSyntax)context.Node;
+ if (context.SemanticModel.GetSymbolInfo(invocation, cancellationToken).Symbol is IMethodSymbol method
+ && method.ContainingType.ToDisplayString() != Constants.ActivationExtensionsDisplayName)
+ {
+ return null;
+ }
+
+ var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression;
+ return context.SemanticModel.GetTypeInfo(memberAccess.Expression, cancellationToken).Type
+ is INamedTypeSymbol { TypeKind: not TypeKind.Error } receiver
+ ? Create(
+ receiver.OriginalDefinition,
+ LocationInfo.From(invocation.GetLocation()),
+ LanguageSupport.SupportsNullableAnnotations(context.SemanticModel.SyntaxTree),
+ WellKnownTypes.From(context.SemanticModel.Compilation),
+ cancellationToken)
+ : null;
+ }
+
+ /// Builds the model for one requested host.
+ /// The host to wrap, reduced to its original definition.
+ /// The call site, for diagnostics.
+ /// Whether the consumer's language can express an annotation.
+ /// The task types resolved from the consumer compilation.
+ /// A token that cancels the walk.
+ /// The host model.
+ ///
+ /// A generic host is reduced to its original definition so that Foo<int> and
+ /// Foo<string> share one wrapper, generic in the same parameters the host is.
+ ///
+ private static InstanceTargetModel Create(
+ INamedTypeSymbol host,
+ LocationInfo? location,
+ bool supportsNullableAnnotations,
+ WellKnownTypes wellKnownTypes,
+ CancellationToken cancellationToken)
+ {
+ var typeParameters = SymbolHelpers.CollectTypeParameters(host);
+ var typeParameterNames = SymbolHelpers.CreateTypeParameterNames(typeParameters);
+ var typeParameterList = SymbolHelpers.BuildTypeParameterList(typeParameters, typeParameterNames);
+ var typeReference = SymbolHelpers.Display(host, typeParameterNames, supportsNullableAnnotations);
+
+ // Keyed on the unannotated name, so a generated file does not change identity with the language version.
+ var identity = host.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var displayName = host.ToDisplayString();
+ var namespaceName = host.ContainingNamespace.IsGlobalNamespace
+ ? string.Empty
+ : host.ContainingNamespace.ToDisplayString();
+ var wrapperName = GeneratedNames.WrapperName(identity);
+
+ var diagnostics = new List();
+ var events = EventExtractor.Collect(
+ new(host, false, typeParameterNames, supportsNullableAnnotations, location, wellKnownTypes),
+ diagnostics,
+ cancellationToken);
+
+ if (events.IsEmpty)
+ {
+ diagnostics.Add(new(
+ DiagnosticWarnings.NoEvents,
+ location,
+ DiagnosticWarnings.InstanceHostKind,
+ displayName));
+ }
+
+ return new(
+ identity,
+ displayName,
+ SymbolHelpers.EscapeXml(typeReference),
+ GeneratedNames.InstanceHintName(identity),
+ namespaceName,
+ wrapperName,
+ BuildWrapperReference(namespaceName, wrapperName, typeParameterList),
+ typeReference,
+ typeParameterList,
+ SymbolHelpers.BuildConstraints(typeParameters, typeParameterNames, supportsNullableAnnotations),
+ supportsNullableAnnotations,
+ events,
+ diagnostics.Count == 0 ? EquatableArray.Empty : new([.. diagnostics]),
+ location);
+ }
+
+ /// Builds the fully qualified reference to a generated wrapper.
+ /// The wrapper's namespace, or empty for the global namespace.
+ /// The wrapper class name.
+ /// The wrapper's type parameter list.
+ /// The fully qualified wrapper reference.
+ private static string BuildWrapperReference(
+ string namespaceName,
+ string wrapperName,
+ string typeParameterList)
+ {
+ var builder = new PooledStringBuilder();
+ _ = builder.Append("global::");
+ if (namespaceName.Length > 0)
+ {
+ _ = builder.Append(namespaceName).Append('.');
+ }
+
+ return builder.Append(wrapperName).Append(typeParameterList).ToStringAndReturn();
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs
new file mode 100644
index 00000000..dbd47acb
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/LanguageSupport.cs
@@ -0,0 +1,28 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// What the consumer's language version lets the generated source say.
+internal static class LanguageSupport
+{
+ /// Determines whether the consumer's language can express a nullable reference type.
+ /// A syntax tree from the consumer, which carries the language version it was parsed at.
+ /// when annotations and the nullable directive may be emitted.
+ ///
+ /// Everything about the generated file's nullability follows from this one answer: whether it opens with
+ /// #nullable enable, and whether the handler signatures carry the annotations that make them match the
+ /// delegates they are assigned to. Emitting either against an older language version is a compile error in the
+ /// consumer's build, so both are decided together and from the same place.
+ ///
+ ///
+ /// The cast is safe by registration: the generator is declared for C# only, so every tree it is ever handed
+ /// was parsed with C# options.
+ ///
+ internal static bool SupportsNullableAnnotations(SyntaxTree tree) =>
+ ((CSharpParseOptions)tree.Options).LanguageVersion >= LanguageVersion.CSharp8;
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs
new file mode 100644
index 00000000..3c5c4387
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/ProviderResolver.cs
@@ -0,0 +1,83 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Chooses which observable implementation the generated code is written against.
+internal static class ProviderResolver
+{
+ /// Selects the best provider visible to a consumer.
+ /// The consumer compilation.
+ /// The selected provider, or when none is referenced.
+ ///
+ /// Lean wins over reactive when both are referenced, because a consumer that has the lean package on hand is
+ /// asking for the allocation-free payload; falling back to Unit there would be a silent downgrade. Each
+ /// candidate is confirmed by every type its generated code names, so a partial reference set moves on to the
+ /// next candidate instead of emitting source that will not compile.
+ ///
+ internal static ObservableProvider Resolve(Compilation compilation)
+ {
+ if (HasTypes(
+ compilation,
+ Constants.LeanSignalMetadataName,
+ Constants.ScopeMetadataName,
+ Constants.RxVoidMetadataName))
+ {
+ return ObservableProvider.Lean;
+ }
+
+ if (HasTypes(
+ compilation,
+ Constants.ReactiveSignalMetadataName,
+ Constants.ScopeMetadataName,
+ Constants.ReactiveUnitMetadataName))
+ {
+ return ObservableProvider.Reactive;
+ }
+
+ return HasTypes(
+ compilation,
+ Constants.ReactiveObservableMetadataName,
+ Constants.ReactiveDisposableMetadataName,
+ Constants.ReactiveUnitMetadataName)
+ ? ObservableProvider.SystemReactive
+ : ObservableProvider.None;
+ }
+
+ /// Gets the observable factory the provider's generated properties call.
+ /// The selected provider.
+ /// The fully qualified factory method.
+ internal static string ObservableFactory(ObservableProvider provider) => provider switch
+ {
+ ObservableProvider.Lean => Constants.LeanSignalCreate,
+ ObservableProvider.Reactive => Constants.ReactiveSignalCreate,
+ _ => Constants.ObservableCreate,
+ };
+
+ /// Gets the disposable factory the provider's generated unsubscription uses.
+ /// The selected provider.
+ /// The fully qualified factory method.
+ internal static string DisposableFactory(ObservableProvider provider) =>
+ provider == ObservableProvider.SystemReactive ? Constants.DisposableCreate : Constants.ScopeCreate;
+
+ /// Gets the payload type standing in for a parameterless event delegate.
+ /// The selected provider.
+ /// The fully qualified void payload type.
+ internal static string VoidPayloadType(ObservableProvider provider) =>
+ provider == ObservableProvider.Lean ? Constants.RxVoidType : Constants.ReactiveUnitType;
+
+ /// Determines whether every named type is visible to a consumer.
+ /// The consumer compilation.
+ /// The first required metadata name.
+ /// The second required metadata name.
+ /// The third required metadata name.
+ /// when all three types resolve.
+ private static bool HasTypes(Compilation compilation, string first, string second, string third) =>
+ compilation.GetTypeByMetadataName(first) is not null
+ && compilation.GetTypeByMetadataName(second) is not null
+ && compilation.GetTypeByMetadataName(third) is not null;
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs
new file mode 100644
index 00000000..c944f178
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/StaticTargetExtractor.cs
@@ -0,0 +1,146 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Turns each GenerateStaticEventObservables application into the model of the host it names.
+///
+/// Matched on how the attribute is written rather than on the symbol it binds to. The attribute is declared by this
+/// generator's own output, and output is not visible to the pipeline that produced it, so there is no symbol to
+/// match against while the pipeline runs. What the request actually needs - the host type - comes from the
+/// typeof argument, which binds on its own.
+///
+internal static class StaticTargetExtractor
+{
+ /// Cheaply rejects syntax that cannot be a static generation request.
+ /// The node under consideration.
+ /// A token that cancels the check.
+ /// when the node is an assembly-targeted request attribute.
+ internal static bool IsStaticRequestAttribute(SyntaxNode node, CancellationToken cancellationToken)
+ {
+ _ = cancellationToken;
+ return node is AttributeSyntax attribute
+ && attribute.Parent is AttributeListSyntax { Target: { } target }
+ && target.Identifier.IsKind(SyntaxKind.AssemblyKeyword)
+ && IsRequestAttributeName(attribute.Name);
+ }
+
+ /// Resolves one static request into the host it names.
+ /// The semantic context for the candidate attribute.
+ /// A token that cancels the resolution.
+ /// The requested host, or when the attribute names nothing usable.
+ ///
+ /// An attribute that names nothing usable - written without an argument, or with one that is not a type - is
+ /// skipped rather than diagnosed: the consumer is already being told about it by the compiler, and a half-typed
+ /// attribute should not add a second complaint on every keystroke.
+ ///
+ internal static StaticTargetModel? Extract(GeneratorSyntaxContext context, CancellationToken cancellationToken)
+ {
+ var attribute = (AttributeSyntax)context.Node;
+ return attribute.ArgumentList is { Arguments.Count: 1 } arguments
+ && arguments.Arguments[0].Expression is TypeOfExpressionSyntax typeOfExpression
+ && context.SemanticModel.GetSymbolInfo(typeOfExpression.Type, cancellationToken).Symbol
+ is INamedTypeSymbol host
+ ? Create(
+ host.OriginalDefinition,
+ LocationInfo.From(attribute.GetLocation()),
+ LanguageSupport.SupportsNullableAnnotations(context.SemanticModel.SyntaxTree),
+ WellKnownTypes.From(context.SemanticModel.Compilation),
+ cancellationToken)
+ : null;
+ }
+
+ /// Determines whether an attribute is written with this generator's request name.
+ /// The attribute name as written.
+ /// when the name matches, with or without the suffix.
+ private static bool IsRequestAttributeName(NameSyntax name)
+ {
+ var identifier = SelectRightmostIdentifier(name);
+ return identifier is Constants.StaticRequestAttributeName
+ or Constants.StaticRequestAttributeQualifiedName;
+ }
+
+ /// Gets the last identifier of a possibly qualified attribute name.
+ /// The attribute name as written.
+ /// The rightmost identifier, or an empty string for a name with none.
+ private static string SelectRightmostIdentifier(NameSyntax name) => name switch
+ {
+ IdentifierNameSyntax identifier => identifier.Identifier.ValueText,
+ QualifiedNameSyntax qualified => SelectRightmostIdentifier(qualified.Right),
+ AliasQualifiedNameSyntax aliased => aliased.Name.Identifier.ValueText,
+ _ => string.Empty,
+ };
+
+ /// Builds the model for one requested static host.
+ /// The host to expose, reduced to its original definition.
+ /// The attribute application, for diagnostics.
+ /// Whether the consumer's language can express an annotation.
+ /// The task types resolved from the consumer compilation.
+ /// A token that cancels the walk.
+ /// The host model.
+ ///
+ /// A generic host is refused outright: its static events belong to each closed construction rather than to the
+ /// open type, and the generated class has no receiver to infer type arguments from.
+ ///
+ private static StaticTargetModel Create(
+ INamedTypeSymbol host,
+ LocationInfo? location,
+ bool supportsNullableAnnotations,
+ WellKnownTypes wellKnownTypes,
+ CancellationToken cancellationToken)
+ {
+ var identity = host.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ var displayName = host.ToDisplayString();
+ var namespaceName = host.ContainingNamespace.IsGlobalNamespace
+ ? string.Empty
+ : host.ContainingNamespace.ToDisplayString();
+
+ if (host.IsGenericType)
+ {
+ return new(
+ identity,
+ displayName,
+ namespaceName,
+ supportsNullableAnnotations,
+ EquatableArray.Empty,
+ new([
+ new DiagnosticInfo(
+ DiagnosticWarnings.UnsupportedEvent,
+ location,
+ displayName,
+ DiagnosticWarnings.GenericStaticHostReason),
+ ]),
+ location);
+ }
+
+ var diagnostics = new List();
+ var events = EventExtractor.Collect(
+ new(host, true, null, supportsNullableAnnotations, location, wellKnownTypes),
+ diagnostics,
+ cancellationToken);
+
+ if (events.IsEmpty)
+ {
+ diagnostics.Add(new(
+ DiagnosticWarnings.NoEvents,
+ location,
+ DiagnosticWarnings.StaticHostKind,
+ displayName));
+ }
+
+ return new(
+ identity,
+ displayName,
+ namespaceName,
+ supportsNullableAnnotations,
+ events,
+ diagnostics.Count == 0 ? EquatableArray.Empty : new([.. diagnostics]),
+ location);
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs
new file mode 100644
index 00000000..fb116e7d
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/SymbolHelpers.cs
@@ -0,0 +1,287 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Renders symbols as the source fragments a model carries, so no symbol reaches the emitter.
+internal static class SymbolHelpers
+{
+ /// How much room an escaped documentation string is expected to need beyond the original.
+ private const int EscapeGrowthFactor = 2;
+
+ /// The characters that cannot appear literally in a generated documentation comment.
+ private static readonly char[] XmlSpecialCharacters = ['&', '<', '>', '"', '\''];
+
+ /// Renders a type without nullable annotations, for a consumer whose language predates them.
+ private static readonly SymbolDisplayFormat ObliviousFormat = SymbolDisplayFormat.FullyQualifiedFormat;
+
+ /// Renders a type with its nullable annotations.
+ ///
+ /// The generated handler has to match the delegate it is assigned to exactly. A delegate declared with an
+ /// annotated parameter - EventHandler and its sender being the one nearly every event goes through -
+ /// does not match a handler that declares the same parameter unannotated, and the consumer's build says so.
+ ///
+ private static readonly SymbolDisplayFormat AnnotatedFormat =
+ SymbolDisplayFormat.FullyQualifiedFormat.AddMiscellaneousOptions(
+ SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);
+
+ /// Renders a type as a fully qualified reference, substituting renamed type parameters.
+ /// The type to render.
+ /// The generated type-parameter names, or null when none were renamed.
+ /// Whether the consumer's language can express an annotation.
+ /// The fully qualified type reference.
+ ///
+ /// Walking display parts rather than the finished string is what makes the substitution safe: a part carrying a
+ /// type parameter is identified by its symbol, so a parameter named T is replaced while a type whose name
+ /// merely contains T is left alone.
+ ///
+ internal static string Display(
+ ITypeSymbol symbol,
+ IReadOnlyDictionary? typeParameterNames,
+ bool supportsNullableAnnotations)
+ {
+ var format = supportsNullableAnnotations ? AnnotatedFormat : ObliviousFormat;
+ if (typeParameterNames is null || typeParameterNames.Count == 0)
+ {
+ return symbol.ToDisplayString(format);
+ }
+
+ var builder = new PooledStringBuilder();
+ foreach (var part in symbol.ToDisplayParts(format))
+ {
+ if (part.Symbol is ITypeParameterSymbol parameter
+ && typeParameterNames.TryGetValue(parameter, out var replacement))
+ {
+ _ = builder.Append(replacement);
+ continue;
+ }
+
+ _ = builder.Append(part.ToString());
+ }
+
+ return builder.ToStringAndReturn();
+ }
+
+ /// Collects the type parameters a wrapper has to redeclare, outermost container first.
+ /// The host type.
+ /// The complete ordered type-parameter list.
+ ///
+ /// A wrapper for a nested generic sits at namespace level, so it has to redeclare every parameter its host
+ /// inherits from its containing types as well as its own.
+ ///
+ internal static List CollectTypeParameters(INamedTypeSymbol type)
+ {
+ var containers = new Stack();
+ for (INamedTypeSymbol? current = type; current is not null; current = current.ContainingType)
+ {
+ containers.Push(current);
+ }
+
+ var result = new List();
+ while (containers.Count > 0)
+ {
+ result.AddRange(containers.Pop().TypeParameters);
+ }
+
+ return result;
+ }
+
+ /// Assigns each type parameter a name that is unique across the flattened list.
+ /// The ordered type parameters.
+ /// The symbol-to-generated-name mapping.
+ ///
+ /// Flattening a nested generic can collide two parameters that were distinct in their own scopes -
+ /// Outer<T>.Inner<T> being the usual shape - so the second one is suffixed rather than
+ /// silently shadowing the first.
+ ///
+ internal static Dictionary CreateTypeParameterNames(
+ List typeParameters)
+ {
+ var result = new Dictionary(SymbolEqualityComparer.Default);
+ var usedNames = new HashSet(StringComparer.Ordinal);
+ foreach (var parameter in typeParameters)
+ {
+ var name = parameter.Name;
+ var suffix = 1;
+ while (!usedNames.Add(name))
+ {
+ suffix++;
+ name = parameter.Name + suffix.ToString(CultureInfo.InvariantCulture);
+ }
+
+ result.Add(parameter, EscapeIdentifier(name));
+ }
+
+ return result;
+ }
+
+ /// Renders the type-parameter declaration list.
+ /// The ordered type parameters.
+ /// The generated type-parameter names.
+ /// The declaration list, or an empty string for a non-generic host.
+ internal static string BuildTypeParameterList(
+ List typeParameters,
+ IReadOnlyDictionary typeParameterNames)
+ {
+ if (typeParameters.Count == 0)
+ {
+ return string.Empty;
+ }
+
+ var builder = new PooledStringBuilder();
+ _ = builder.Append('<');
+ for (var index = 0; index < typeParameters.Count; index++)
+ {
+ if (index > 0)
+ {
+ _ = builder.Append(", ");
+ }
+
+ _ = builder.Append(typeParameterNames[typeParameters[index]]);
+ }
+
+ return builder.Append('>').ToStringAndReturn();
+ }
+
+ /// Renders the generic constraint clauses, one per line and without indentation.
+ /// The ordered type parameters.
+ /// The generated type-parameter names.
+ /// Whether the consumer's language can express an annotation.
+ /// The constraint clauses, or an empty string when nothing is constrained.
+ ///
+ /// Left unindented because the same clauses are emitted at two different depths - once on the wrapper class and
+ /// once on the activation overload - and the emitter is what knows which.
+ ///
+ internal static string BuildConstraints(
+ List typeParameters,
+ IReadOnlyDictionary typeParameterNames,
+ bool supportsNullableAnnotations)
+ {
+ var builder = new PooledStringBuilder();
+ foreach (var parameter in typeParameters)
+ {
+ var clause = new PooledStringBuilder();
+ AppendConstraintClause(clause, parameter, typeParameterNames, supportsNullableAnnotations);
+ if (clause.Length == 0)
+ {
+ clause.Return();
+ continue;
+ }
+
+ _ = builder.Append("where ").Append(typeParameterNames[parameter]).Append(" : ")
+ .Append(clause).AppendLine();
+ }
+
+ return builder.ToStringAndReturn();
+ }
+
+ /// Escapes an identifier that collides with a C# keyword.
+ /// The identifier text.
+ /// The escaped identifier.
+ internal static string EscapeIdentifier(string value) =>
+ SyntaxFacts.GetKeywordKind(value) == SyntaxKind.None ? value : $"@{value}";
+
+ /// Escapes text destined for a generated documentation comment.
+ /// The text to escape.
+ /// The escaped text, or the original instance when nothing needed escaping.
+ internal static string EscapeXml(string value)
+ {
+ // Most references have nothing to escape, so the scan is what keeps the common case allocation-free.
+ if (value.IndexOfAny(XmlSpecialCharacters) < 0)
+ {
+ return value;
+ }
+
+ var builder = new PooledStringBuilder(value.Length * EscapeGrowthFactor);
+ for (var current = 0; current < value.Length; current++)
+ {
+ _ = value[current] switch
+ {
+ '&' => builder.Append("&"),
+ '<' => builder.Append("<"),
+ '>' => builder.Append(">"),
+ '"' => builder.Append("""),
+ '\'' => builder.Append("'"),
+ var character => builder.Append(character),
+ };
+ }
+
+ return builder.ToStringAndReturn();
+ }
+
+ /// Appends one type parameter's comma-separated constraints.
+ /// The destination builder.
+ /// The constrained type parameter.
+ /// The generated type-parameter names.
+ /// Whether the consumer's language can express an annotation.
+ ///
+ /// The primary constraint has to come first and only one of the four forms may appear, which is why they are
+ /// tested in order rather than accumulated.
+ ///
+ private static void AppendConstraintClause(
+ PooledStringBuilder builder,
+ ITypeParameterSymbol parameter,
+ IReadOnlyDictionary typeParameterNames,
+ bool supportsNullableAnnotations)
+ {
+ var primary = SelectPrimaryConstraint(parameter, supportsNullableAnnotations);
+ if (primary.Length > 0)
+ {
+ _ = builder.Append(primary);
+ }
+
+ foreach (var constraintType in parameter.ConstraintTypes)
+ {
+ _ = AppendSeparator(builder)
+ .Append(Display(constraintType, typeParameterNames, supportsNullableAnnotations));
+ }
+
+ if (!parameter.HasConstructorConstraint)
+ {
+ return;
+ }
+
+ _ = AppendSeparator(builder).Append("new()");
+ }
+
+ /// Selects the single primary constraint a type parameter may carry.
+ /// The type parameter.
+ /// Whether the consumer's language can express an annotation.
+ /// The primary constraint keyword, or an empty string when there is none.
+ private static string SelectPrimaryConstraint(ITypeParameterSymbol parameter, bool supportsNullableAnnotations)
+ {
+ if (parameter.HasUnmanagedTypeConstraint)
+ {
+ return "unmanaged";
+ }
+
+ if (parameter.HasValueTypeConstraint)
+ {
+ return "struct";
+ }
+
+ if (parameter.HasReferenceTypeConstraint)
+ {
+ // A referenced assembly can declare `class?` whatever the consumer's language version is, so the
+ // annotation has to be dropped rather than repeated when the consumer could not have written it.
+ return supportsNullableAnnotations
+ && parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated
+ ? "class?"
+ : "class";
+ }
+
+ return parameter.HasNotNullConstraint ? "notnull" : string.Empty;
+ }
+
+ /// Appends the constraint separator when something has already been written.
+ /// The destination builder.
+ /// The builder, for chaining.
+ private static PooledStringBuilder AppendSeparator(PooledStringBuilder builder) =>
+ builder.Length == 0 ? builder : builder.Append(", ");
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs
new file mode 100644
index 00000000..261521a9
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/TargetCollections.cs
@@ -0,0 +1,113 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Collections.Immutable;
+using ReactiveUI.Primitives.ObservableEvents.CodeGeneration;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// Reconciles the requests found across a compilation into the set of files to generate.
+///
+/// Requests arrive one per call site or attribute, but generated files are keyed on the host or the namespace, so
+/// this is where the two are brought back into line. Both passes keep the order the requests were found in, so the
+/// generated output does not shuffle when an unrelated file is edited.
+///
+internal static class TargetCollections
+{
+ /// The largest request count that cannot contain a duplicate.
+ private const int SingleTarget = 1;
+
+ /// Drops repeated requests for one host, keeping the first.
+ /// The requested hosts, in source order.
+ /// The distinct hosts.
+ internal static InstanceTargetModel[] Deduplicate(ImmutableArray targets)
+ {
+ if (targets.Length <= SingleTarget)
+ {
+ return [.. targets];
+ }
+
+ var result = new List(targets.Length);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var target in targets)
+ {
+ if (seen.Add(target.Identity))
+ {
+ result.Add(target);
+ }
+ }
+
+ return [.. result];
+ }
+
+ /// Drops repeated requests for one static host, keeping the first.
+ /// The requested hosts, in source order.
+ /// The distinct hosts.
+ internal static StaticTargetModel[] Deduplicate(ImmutableArray targets)
+ {
+ if (targets.Length <= SingleTarget)
+ {
+ return [.. targets];
+ }
+
+ var result = new List(targets.Length);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var target in targets)
+ {
+ if (seen.Add(target.Identity))
+ {
+ result.Add(target);
+ }
+ }
+
+ return [.. result];
+ }
+
+ /// Collects the static events of every host in a namespace into that namespace's one file.
+ /// The distinct static hosts, in source order.
+ /// One model per namespace that has at least one event to expose.
+ internal static StaticNamespaceModel[] GroupByNamespace(ImmutableArray targets)
+ {
+ if (targets.IsEmpty)
+ {
+ return [];
+ }
+
+ var order = new List();
+ var byNamespace = new Dictionary>(StringComparer.Ordinal);
+ var supportsNullableAnnotations = false;
+ foreach (var target in targets)
+ {
+ if (target.Events.IsEmpty)
+ {
+ continue;
+ }
+
+ // Every request came out of the same compilation, so they agree on what its language allows.
+ supportsNullableAnnotations = target.SupportsNullableAnnotations;
+ if (!byNamespace.TryGetValue(target.Namespace, out var events))
+ {
+ events = [];
+ byNamespace.Add(target.Namespace, events);
+ order.Add(target.Namespace);
+ }
+
+ events.AddRange(target.Events.AsArray());
+ }
+
+ var result = new StaticNamespaceModel[order.Count];
+ for (var index = 0; index < order.Count; index++)
+ {
+ var namespaceName = order[index];
+ result[index] = new(
+ GeneratedNames.StaticHintName(namespaceName),
+ namespaceName,
+ supportsNullableAnnotations,
+ new([.. byNamespace[namespaceName]]));
+ }
+
+ return result;
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs
new file mode 100644
index 00000000..78b07aae
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Helpers/WellKnownTypes.cs
@@ -0,0 +1,25 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Helpers;
+
+/// The framework types an event delegate is allowed to return, resolved once per extraction.
+/// The resolved task type, or null when the consumer cannot see it.
+/// The resolved value task type, or null when the consumer cannot see it.
+///
+/// Resolved up front rather than per event, because an async event host declares many events and each one would
+/// otherwise repeat the same two metadata lookups.
+///
+internal readonly record struct WellKnownTypes(INamedTypeSymbol? Task, INamedTypeSymbol? ValueTask)
+{
+ /// Resolves the types from a consumer compilation.
+ /// The consumer compilation.
+ /// The resolved types.
+ internal static WellKnownTypes From(Compilation compilation) =>
+ new(
+ compilation.GetTypeByMetadataName(Constants.TaskMetadataName),
+ compilation.GetTypeByMetadataName(Constants.ValueTaskMetadataName));
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs
new file mode 100644
index 00000000..37cd41e1
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/ActivationModel.cs
@@ -0,0 +1,24 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// One strongly typed Events() overload, which replaces the placeholder for a given host.
+/// The fully qualified host reference the overload accepts.
+/// The fully qualified wrapper reference the overload returns.
+/// The overload's type parameter list, or empty for a non-generic host.
+/// One where clause per line without indentation, or empty when unconstrained.
+/// The host reference, escaped for the generated documentation comment.
+/// Whether the consumer's language can express an annotation.
+///
+/// Kept apart from so the one file carrying every overload only re-emits when an
+/// overload signature actually moves, rather than whenever any wrapper's events change.
+///
+internal sealed record ActivationModel(
+ string TypeReference,
+ string WrapperReference,
+ string TypeParameterList,
+ string Constraints,
+ string DocumentationName,
+ bool SupportsNullableAnnotations);
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs
new file mode 100644
index 00000000..ef1f2ba0
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/DiagnosticInfo.cs
@@ -0,0 +1,45 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// A diagnostic held as values, so it can ride along in a model until it is reported.
+/// The descriptor to report.
+/// Where to point, or when the request has no source location.
+/// The first message argument.
+/// The second message argument, when the descriptor takes one.
+///
+/// A carries a and therefore a syntax tree,
+/// which the pipeline can neither compare nor safely cache. Extraction records what to say and where; the source
+/// output turns it back into a diagnostic.
+///
+internal sealed record DiagnosticInfo(
+ DiagnosticDescriptor Descriptor,
+ LocationInfo? Location,
+ string FirstArgument,
+ string? SecondArgument)
+{
+ /// Creates a single-argument diagnostic record.
+ /// The descriptor to report.
+ /// Where to point.
+ /// The only message argument.
+ /// The diagnostic record.
+ internal static DiagnosticInfo Create(
+ DiagnosticDescriptor descriptor,
+ LocationInfo? location,
+ string argument) =>
+ new(descriptor, location, argument, null);
+
+ /// Rebuilds the diagnostic for reporting.
+ /// The reportable diagnostic.
+ internal Diagnostic ToDiagnostic()
+ {
+ var location = Location?.ToLocation() ?? Microsoft.CodeAnalysis.Location.None;
+ return SecondArgument is null
+ ? Diagnostic.Create(Descriptor, location, FirstArgument)
+ : Diagnostic.Create(Descriptor, location, FirstArgument, SecondArgument);
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs
new file mode 100644
index 00000000..1f662292
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/EquatableArray.cs
@@ -0,0 +1,129 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// An array that compares by value, so it can sit inside an incremental-pipeline model.
+/// The element type, which must itself compare by value.
+///
+/// The pipeline decides whether to re-run a downstream step by asking whether the model it produced equals the one
+/// from the previous run. An array compares by reference, so a model carrying a bare array is never equal to its
+/// predecessor and every step below it re-runs on every keystroke. Wrapping the array here is what makes the
+/// per-target caching real. A readonly struct so the value sits inline in its owning record rather than adding a
+/// heap object per collection.
+///
+internal readonly struct EquatableArray : IEquatable>
+ where T : notnull, IEquatable
+{
+ /// The seed of the deterministic hash-combine loop.
+ private const int HashSeed = 17;
+
+ /// The multiplier of the deterministic hash-combine loop.
+ private const int HashMultiplier = 31;
+
+ /// The wrapped elements, or null when default-constructed.
+ private readonly T[]? _values;
+
+ /// The hash computed once at construction, because the pipeline asks for it repeatedly.
+ private readonly int _hashCode;
+
+ /// Initializes a new instance of the struct.
+ /// The elements to wrap; ownership passes to this instance.
+ internal EquatableArray(T[] values)
+ {
+ _values = values;
+ _hashCode = ComputeHashCode(values);
+ }
+
+ /// Gets an empty array.
+ internal static EquatableArray Empty => default;
+
+ /// Gets a value indicating whether there are no elements.
+ internal bool IsEmpty => _values is null || _values.Length == 0;
+
+ /// Determines whether two arrays hold equal elements.
+ /// The first array.
+ /// The second array.
+ /// when the arrays are element-wise equal.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right);
+
+ /// Determines whether two arrays differ.
+ /// The first array.
+ /// The second array.
+ /// when the arrays are not element-wise equal.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right);
+
+ ///
+ ///
+ /// A defaulted instance and one wrapping a zero-length array are the same value here. Treating them as
+ /// different would make an extraction that happened to build an empty array compare unequal to one that
+ /// returned , and silently cost the caching this type exists for.
+ ///
+ public bool Equals(EquatableArray other)
+ {
+ var values = _values;
+ var otherValues = other._values;
+ if (ReferenceEquals(values, otherValues))
+ {
+ return true;
+ }
+
+ var length = values?.Length ?? 0;
+ if (length != (otherValues?.Length ?? 0))
+ {
+ return false;
+ }
+
+ for (var index = 0; index < length; index++)
+ {
+ if (!values![index].Equals(otherValues![index]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other);
+
+ ///
+ public override int GetHashCode() => _hashCode;
+
+ /// Gets the wrapped elements for iteration without allocating an enumerator.
+ /// The backing array, or an empty array when default-constructed.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal T[] AsArray() => _values ?? [];
+
+ /// Computes the deterministic hash of the elements.
+ /// The elements to hash.
+ /// The combined hash.
+ ///
+ /// Empty hashes to zero, which is what a default-constructed instance keeps in its field without calling
+ /// here - so the two forms of empty that calls equal hash alike.
+ ///
+ private static int ComputeHashCode(T[] values)
+ {
+ if (values.Length == 0)
+ {
+ return 0;
+ }
+
+ unchecked
+ {
+ var hash = HashSeed;
+ for (var index = 0; index < values.Length; index++)
+ {
+ hash = (hash * HashMultiplier) + values[index].GetHashCode();
+ }
+
+ return hash;
+ }
+ }
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs
new file mode 100644
index 00000000..52c03a98
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/EventModel.cs
@@ -0,0 +1,36 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// One event, reduced to the exact fragments its generated observable property is assembled from.
+/// The generated property name, already escaped.
+/// The subscription target and event name, already escaped.
+/// The observable payload type, or empty when the delegate takes no parameters.
+/// The value passed to OnNext, or empty when the delegate takes no parameters.
+/// The generated handler's parameter list.
+/// The generated handler's return type.
+/// The handler's return expression, or empty when it returns void.
+/// Whether the generated property is static.
+/// The event name, escaped for the generated documentation comment.
+///
+/// Everything the emitter needs is a string by this point: no symbol, syntax node, or compilation survives into the
+/// pipeline. A parameterless delegate leaves the payload fields empty rather than naming a void type, because which
+/// void type applies is the one thing that depends on the provider - keeping it out here is what lets a model stay
+/// cached when only the consumer's references change.
+///
+internal sealed record EventModel(
+ string PropertyName,
+ string EventAccess,
+ string PayloadType,
+ string PayloadValue,
+ string HandlerParameters,
+ string HandlerReturnType,
+ string HandlerReturnValue,
+ bool IsStatic,
+ string DocumentationName)
+{
+ /// Gets a value indicating whether the delegate takes no parameters and signals a void payload.
+ internal bool HasVoidPayload => PayloadType.Length == 0;
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs
new file mode 100644
index 00000000..662534d9
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/InstanceTargetModel.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// One host type reached through an Events() call, ready to emit as a wrapper and an overload.
+/// The host's fully qualified name, which is both the dedup key and the hash source.
+/// The host's readable name, as it appears in a diagnostic message.
+/// The host reference, escaped for the generated documentation comment.
+/// The generated file name for the wrapper.
+/// The namespace to emit into, or empty for the global namespace.
+/// The generated wrapper class name.
+/// The fully qualified wrapper reference, including type arguments.
+/// The fully qualified host reference, including type arguments.
+/// The wrapper's type parameter list, or empty for a non-generic host.
+/// One where clause per line without indentation, or empty when unconstrained.
+/// Whether the consumer's language can express an annotation.
+/// The events to expose.
+/// What extraction found wrong, reported once a provider is known to exist.
+/// The activation call site, for the diagnostics that point at the request itself.
+internal sealed record InstanceTargetModel(
+ string Identity,
+ string DisplayName,
+ string DocumentationName,
+ string HintName,
+ string Namespace,
+ string WrapperName,
+ string WrapperReference,
+ string TypeReference,
+ string TypeParameterList,
+ string Constraints,
+ bool SupportsNullableAnnotations,
+ EquatableArray Events,
+ EquatableArray Diagnostics,
+ LocationInfo? Location)
+{
+ /// Creates the activation overload this host's wrapper is reached through.
+ /// The overload model.
+ ///
+ /// Projected out rather than stored, so the one file carrying every overload compares equal - and stays
+ /// uncompiled - when a host's events change but its signature does not.
+ ///
+ internal ActivationModel ToActivation() =>
+ new(
+ TypeReference,
+ WrapperReference,
+ TypeParameterList,
+ Constraints,
+ DocumentationName,
+ SupportsNullableAnnotations);
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs
new file mode 100644
index 00000000..8fa140fb
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/LocationInfo.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Text;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// Where a diagnostic points, reduced to values the incremental pipeline can compare.
+/// The source file the request was written in.
+/// The span within that file.
+/// The line and character span within that file.
+///
+/// A holds onto its syntax tree, which would pin a whole compilation in the pipeline's cache
+/// and never compare equal between runs. Keeping the three values it is built from lets the location survive in a
+/// model and be rebuilt at the point a diagnostic is actually reported.
+///
+internal sealed record LocationInfo(string FilePath, TextSpan TextSpan, LinePositionSpan LineSpan)
+{
+ /// Reduces a Roslyn location to its comparable values.
+ /// The location to reduce, which may carry no source.
+ /// The reduced location, or when there is no source to point at.
+ internal static LocationInfo? From(Location? location)
+ {
+ if (location?.SourceTree is null)
+ {
+ return null;
+ }
+
+ var lineSpan = location.GetLineSpan();
+ return new(lineSpan.Path, location.SourceSpan, lineSpan.Span);
+ }
+
+ /// Reduces the location of whatever a syntax reference points at.
+ /// The reference to reduce, which is absent for an attribute read from metadata.
+ /// A token that cancels resolving the referenced syntax.
+ /// The reduced location, or when there is no source to point at.
+ internal static LocationInfo? From(SyntaxReference? reference, CancellationToken cancellationToken) =>
+ reference is null ? null : From(reference.GetSyntax(cancellationToken).GetLocation());
+
+ /// Rebuilds a Roslyn location for reporting.
+ /// The rebuilt location.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal Location ToLocation() => Location.Create(FilePath, TextSpan, LineSpan);
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs
new file mode 100644
index 00000000..8ff07a0f
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/ObservableProvider.cs
@@ -0,0 +1,25 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// The observable implementation the generated code is written against.
+///
+/// Resolved once from the consumer's references and flowed into emission as a value, so that the far more expensive
+/// event extraction does not have to re-run when only the reference set moves.
+///
+internal enum ObservableProvider
+{
+ /// No supported observable factory is visible; nothing can be generated.
+ None = 0,
+
+ /// The lean ReactiveUI.Primitives implementation.
+ Lean = 1,
+
+ /// The ReactiveUI.Primitives.Reactive implementation.
+ Reactive = 2,
+
+ /// Standalone System.Reactive.
+ SystemReactive = 3,
+}
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs
new file mode 100644
index 00000000..f9ccd3e8
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticNamespaceModel.cs
@@ -0,0 +1,20 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// Every static event requested in one namespace, which share a single generated RxEvents class.
+/// The generated file name for this namespace.
+/// The namespace to emit into, or empty for the global namespace.
+/// Whether the consumer's language can express an annotation.
+/// The static events to expose, in request order.
+///
+/// Grouping happens after deduplication so that adding a request in one namespace leaves every other namespace's
+/// file byte-identical, and therefore uncached only where it actually changed.
+///
+internal sealed record StaticNamespaceModel(
+ string HintName,
+ string Namespace,
+ bool SupportsNullableAnnotations,
+ EquatableArray Events);
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticTargetModel.cs b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticTargetModel.cs
new file mode 100644
index 00000000..60501f33
--- /dev/null
+++ b/src/ReactiveUI.Primitives.ObservableEvents/Models/StaticTargetModel.cs
@@ -0,0 +1,22 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Models;
+
+/// One host named by a GenerateStaticEventObservables attribute.
+/// The host's fully qualified name, used to drop repeated requests.
+/// The host's readable name, as it appears in a diagnostic message.
+/// The namespace whose RxEvents class receives these properties.
+/// Whether the consumer's language can express an annotation.
+/// The static events to expose.
+/// What extraction found wrong, reported once a provider is known to exist.
+/// The attribute application, for the diagnostics that point at the request itself.
+internal sealed record StaticTargetModel(
+ string Identity,
+ string DisplayName,
+ string Namespace,
+ bool SupportsNullableAnnotations,
+ EquatableArray Events,
+ EquatableArray Diagnostics,
+ LocationInfo? Location);
diff --git a/src/ReactiveUI.Primitives.ObservableEvents/ReactiveUI.Primitives.ObservableEvents.csproj b/src/ReactiveUI.Primitives.ObservableEvents/ReactiveUI.Primitives.ObservableEvents.csproj
index 8f5d1695..c4dbf72c 100644
--- a/src/ReactiveUI.Primitives.ObservableEvents/ReactiveUI.Primitives.ObservableEvents.csproj
+++ b/src/ReactiveUI.Primitives.ObservableEvents/ReactiveUI.Primitives.ObservableEvents.csproj
@@ -10,15 +10,38 @@
trueAnalyzerfalse
+
+ falseIncremental source generator that exposes .NET events as observables for ReactiveUI.Primitives, ReactiveUI.Primitives.Reactive, and standalone System.Reactive projects.reactiveui;primitives;system.reactive;source-generator;analyzer;observable;events
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ReactiveUI.Primitives.slnx b/src/ReactiveUI.Primitives.slnx
index dab75d97..4c902ec4 100644
--- a/src/ReactiveUI.Primitives.slnx
+++ b/src/ReactiveUI.Primitives.slnx
@@ -1,6 +1,7 @@
+
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs
new file mode 100644
index 00000000..202e8012
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/CorpusSize.cs
@@ -0,0 +1,19 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+/// How much event-bearing source the benchmarks put in front of the generator.
+/// Public because BenchmarkDotNet reads it off a public benchmark parameter.
+public enum CorpusSize
+{
+ /// One wrapped host, the shape a small view model has.
+ Small = 0,
+
+ /// Ten wrapped hosts, the shape a feature area has.
+ Medium = 1,
+
+ /// Fifty wrapped hosts, the shape a large application or a control library has.
+ Large = 2,
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs
new file mode 100644
index 00000000..4dcd64fa
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventCorpus.cs
@@ -0,0 +1,152 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using System.Runtime.CompilerServices;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+/// Builds the consumer source the generator is measured against.
+///
+///
+/// Every host carries one of each delegate shape the generator handles - the conventional sender and arguments
+/// pair, a parameterless action, a single payload, a multi-parameter delegate that becomes a tuple, and a
+/// task-returning handler - because the per-event work differs between them and a corpus of only the easy shape
+/// would flatter the emitter.
+///
+///
+/// One host per file, as real code is laid out. That is what makes the incremental cases mean what they claim:
+/// editing one host has to be editing one file, or the measurement is of re-parsing the whole corpus instead.
+///
+///
+internal static class EventCorpus
+{
+ /// The file name of the shared declarations every host file depends on.
+ internal const string SharedFileName = "Shared.cs";
+
+ /// Source that no request depends on, standing in for an unrelated keystroke.
+ internal const string UnrelatedSource = """
+ namespace Corpus
+ {
+ internal static class Unrelated
+ {
+ internal static int Value => 1;
+ }
+ }
+ """;
+
+ /// The file name the unrelated source is given.
+ internal const string UnrelatedFileName = "Unrelated.cs";
+
+ /// The delegates, static host, and static request every host file shares.
+ private const string SharedSource = """
+ using System;
+ using System.Threading.Tasks;
+ using ReactiveUI.Primitives.ObservableEvents;
+
+ [assembly: GenerateStaticEventObservables(typeof(Corpus.StaticHost))]
+
+ namespace Corpus
+ {
+ public delegate void ManyHandler(string name, int value);
+
+ public delegate Task AsyncHandler(int value);
+
+ public static class StaticHost
+ {
+ public static event Action? GlobalChanged;
+ }
+ }
+ """;
+
+ /// The number of hosts in the small corpus.
+ private const int SmallHostCount = 1;
+
+ /// The number of hosts in the medium corpus.
+ private const int MediumHostCount = 10;
+
+ /// The number of hosts in the large corpus.
+ private const int LargeHostCount = 50;
+
+ /// Builds every source file in a corpus, shared declarations first.
+ /// The corpus size.
+ /// The file name and text of each file.
+ internal static List<(string Path, string Text)> FilesFor(CorpusSize size)
+ {
+ var hosts = HostCountFor(size);
+ var files = new List<(string Path, string Text)>(hosts + 1) { (SharedFileName, SharedSource) };
+ for (var index = 0; index < hosts; index++)
+ {
+ files.Add((HostFileName(index), HostSource(index, false)));
+ }
+
+ return files;
+ }
+
+ /// Builds one host's file with an extra event on it.
+ /// The host index.
+ /// The file text.
+ ///
+ /// The edit that has to invalidate exactly one wrapper: the activation overload's signature is untouched, every
+ /// other host is untouched, and only this host's own generated file has anything new to say.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static string HostSourceWithAddedEvent(int index) => HostSource(index, true);
+
+ /// Gets the file name a host is declared in.
+ /// The host index.
+ /// The file name.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static string HostFileName(int index) =>
+ $"Host{index.ToString(CultureInfo.InvariantCulture)}.cs";
+
+ /// Gets the number of hosts a corpus size contains.
+ /// The corpus size.
+ /// The host count.
+ internal static int HostCountFor(CorpusSize size) => size switch
+ {
+ CorpusSize.Small => SmallHostCount,
+ CorpusSize.Medium => MediumHostCount,
+ _ => LargeHostCount,
+ };
+
+ /// Builds one host, its events, and the call site that asks for its wrapper.
+ /// The host index, which makes every declared name unique.
+ /// Whether to declare one more event than the corpus normally has.
+ /// The file text.
+ private static string HostSource(int index, bool withAddedEvent)
+ {
+ var suffix = index.ToString(CultureInfo.InvariantCulture);
+ var added = withAddedEvent
+ ? $"\n public event Action? Added{suffix};\n"
+ : string.Empty;
+
+ return $$"""
+ using System;
+ using ReactiveUI.Primitives.ObservableEvents;
+
+ namespace Corpus
+ {
+ public sealed class Host{{suffix}}
+ {
+ public event EventHandler? Changed{{suffix}};
+
+ public event Action? Ready{{suffix}};
+
+ public event Action? Counted{{suffix}};
+
+ public event ManyHandler? Many{{suffix}};
+
+ public event AsyncHandler? Awaited{{suffix}};
+ {{added}} }
+
+ public static class Consumer{{suffix}}
+ {
+ public static IObservable Observe(Host{{suffix}} host) =>
+ host.Events().Changed{{suffix}};
+ }
+ }
+ """;
+ }
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs
new file mode 100644
index 00000000..796330eb
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorDriverBenchmarks.cs
@@ -0,0 +1,118 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Diagnosers;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+/// What the observable-event generator itself costs on a build and on a keystroke.
+///
+///
+/// These run the generator only. RunGeneratorsAndUpdateCompilation would also fold the generated trees back
+/// into a new compilation, and at this corpus size that parse-and-rebuild is several times the generator's own
+/// work - large enough to hide the difference between a cached run and a cold one entirely. It is Roslyn's cost
+/// and it is paid whatever the generator does, so it is left out of the measurement.
+///
+///
+/// Cold is a driver that has generated nothing yet, over the whole corpus: the build cost, paid once.
+///
+///
+/// Unchanged is the control, and the one to read first: a primed driver re-run against the very compilation
+/// it was primed against. Nothing has changed, so every cache that can hit does. Whatever it still costs is the
+/// floor, and if that floor sits at the Cold number then the caching is not buying wall-clock - however
+/// thoroughly the driver's own step table reports each step as cached.
+///
+///
+/// The remaining two are what an editor pays per keystroke. UnrelatedEdit touches a file no request depends
+/// on; EventEdit adds an event to exactly one host. Both are only interesting relative to Unchanged:
+/// against Cold they flatter whatever the floor already is.
+///
+///
+/// CPU sampling rather than an allocation column: everything here runs inside Roslyn, whose own work dominates
+/// both the time and the bytes, so a single inclusive total says nothing about which half moved. The trace names
+/// the frames, which is the only way to tell the generator's cost from the compiler's.
+///
+///
+[System.Diagnostics.DebuggerDisplay("{Size}")]
+[SimpleJob(warmupCount: 5, iterationCount: 15)]
+[EventPipeProfiler(EventPipeProfile.CpuSampling)]
+public class EventGeneratorDriverBenchmarks
+{
+ /// The compilation the cold driver runs against.
+ private Compilation _coldCompilation = null!;
+
+ /// The driver that has generated nothing yet.
+ private CSharpGeneratorDriver _coldDriver = null!;
+
+ /// The compilation carrying an edit no request depends on.
+ private Compilation _unrelatedCompilation = null!;
+
+ /// The primed driver for the unrelated edit.
+ private CSharpGeneratorDriver _unrelatedDriver = null!;
+
+ /// The compilation whose first host gained an event.
+ private Compilation _eventEditCompilation = null!;
+
+ /// The primed driver for the event edit.
+ private CSharpGeneratorDriver _eventEditDriver = null!;
+
+ /// The compilation a primed driver is re-run against with nothing changed.
+ private Compilation _unchangedCompilation = null!;
+
+ /// The primed driver for the unchanged control.
+ private CSharpGeneratorDriver _unchangedDriver = null!;
+
+ /// Gets or sets the corpus size under benchmark.
+ [ParamsAllValues]
+ public CorpusSize Size { get; set; }
+
+ /// Builds the cold and primed driver states for the current corpus size.
+ [GlobalSetup]
+ public void Setup()
+ {
+ var cold = GeneratorHarness.CreateColdState(Size);
+ _coldCompilation = cold.Compilation;
+ _coldDriver = cold.Driver;
+
+ var unrelated = GeneratorHarness.CreateUnrelatedEditState(Size);
+ _unrelatedCompilation = unrelated.Compilation;
+ _unrelatedDriver = unrelated.Driver;
+
+ var eventEdit = GeneratorHarness.CreateEventEditState(Size);
+ _eventEditCompilation = eventEdit.Compilation;
+ _eventEditDriver = eventEdit.Driver;
+
+ var unchanged = GeneratorHarness.CreateUnchangedState(Size);
+ _unchangedCompilation = unchanged.Compilation;
+ _unchangedDriver = unchanged.Driver;
+ }
+
+ /// Generates the whole corpus from a driver with nothing cached.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark(Baseline = true)]
+ public GeneratorDriver Cold() => _coldDriver.RunGenerators(_coldCompilation);
+
+ /// Re-runs a primed driver against the compilation it was primed on, with nothing changed.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark]
+ public GeneratorDriver Unchanged() => _unchangedDriver.RunGenerators(_unchangedCompilation);
+
+ /// Re-runs a primed driver after an edit in a file no request depends on.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark]
+ public GeneratorDriver UnrelatedEdit() => _unrelatedDriver.RunGenerators(_unrelatedCompilation);
+
+ /// Re-runs a primed driver after one host gained an event.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark]
+ public GeneratorDriver EventEdit() => _eventEditDriver.RunGenerators(_eventEditCompilation);
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs
new file mode 100644
index 00000000..3b9170ef
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/EventGeneratorGcProfileBenchmarks.cs
@@ -0,0 +1,72 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+using BenchmarkDotNet.Attributes;
+using BenchmarkDotNet.Diagnosers;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+///
+/// Allocation baselines for the generator's cold and incremental runs, with a GC-verbose trace naming the frames
+/// the allocations come from. Opt in with --filter "*GcProfile*".
+///
+///
+///
+/// Only the largest corpus is profiled: it is where an allocation per event or per file actually shows up against
+/// the compiler's own overhead.
+///
+///
+/// The trace is the measurement, not a summary column. An inclusive per-operation total is nearly all Roslyn here
+/// - parsing, symbols, and the driver's own state - so it moves with the compiler rather than with this generator.
+/// What is actionable is which frames allocated, which is what the GC-verbose trace carries.
+///
+///
+[ShortRunJob]
+[EventPipeProfiler(EventPipeProfile.GcVerbose)]
+[System.Diagnostics.DebuggerDisplay("{nameof(EventGeneratorGcProfileBenchmarks),nq}")]
+public class EventGeneratorGcProfileBenchmarks
+{
+ /// The corpus size profiled here.
+ private const CorpusSize ProfiledSize = CorpusSize.Large;
+
+ /// The compilation the cold driver runs against.
+ private Compilation _coldCompilation = null!;
+
+ /// The driver that has generated nothing yet.
+ private CSharpGeneratorDriver _coldDriver = null!;
+
+ /// The compilation carrying an edit no request depends on.
+ private Compilation _unrelatedCompilation = null!;
+
+ /// The primed driver for the unrelated edit.
+ private CSharpGeneratorDriver _unrelatedDriver = null!;
+
+ /// Builds the cold and primed driver states for the profiled corpus size.
+ [GlobalSetup]
+ public void Setup()
+ {
+ var cold = GeneratorHarness.CreateColdState(ProfiledSize);
+ _coldCompilation = cold.Compilation;
+ _coldDriver = cold.Driver;
+
+ var unrelated = GeneratorHarness.CreateUnrelatedEditState(ProfiledSize);
+ _unrelatedCompilation = unrelated.Compilation;
+ _unrelatedDriver = unrelated.Driver;
+ }
+
+ /// Generates the whole corpus from a driver with nothing cached.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark(Baseline = true)]
+ public GeneratorDriver Cold() => _coldDriver.RunGenerators(_coldCompilation);
+
+ /// Re-runs a primed driver after an edit in a file no request depends on.
+ /// The updated driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [Benchmark]
+ public GeneratorDriver UnrelatedEdit() => _unrelatedDriver.RunGenerators(_unrelatedCompilation);
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs
new file mode 100644
index 00000000..8b97eef4
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/GeneratorHarness.cs
@@ -0,0 +1,154 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+/// Builds the compilations and driver states the generator benchmarks run against.
+internal static class GeneratorHarness
+{
+ /// The assembly name given to the throwaway compilation the generator runs against.
+ private const string CompilationAssemblyName = "ObservableEventsCorpus";
+
+ /// The host whose file the event-edit case rewrites.
+ private const int EditedHostIndex = 0;
+
+ /// The parse options every corpus tree is parsed with.
+ private static readonly CSharpParseOptions ParseOptions =
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest);
+
+ /// The compilation options the corpus is compiled with, matching a modern consumer.
+ private static readonly CSharpCompilationOptions CompilationOptions =
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
+ .WithNullableContextOptions(NullableContextOptions.Enable);
+
+ /// Creates a compilation and a driver that has never run, so nothing is cached.
+ /// The corpus size.
+ /// The compilation and a fresh driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateColdState(CorpusSize size) =>
+ (BuildCompilation(size), CreateDriver());
+
+ /// Creates a primed driver and the very compilation it was primed against.
+ /// The corpus size.
+ /// The unchanged compilation and a driver that has already generated once.
+ ///
+ /// The control the other incremental cases are only meaningful against: nothing whatsoever has changed, so
+ /// every cache that can hit must hit. Whatever this still costs is the floor no amount of caching removes, and
+ /// if it sits at the cold number then the caching is not buying wall-clock however green the step table looks.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnchangedState(CorpusSize size) =>
+ RunOnce(size);
+
+ /// Creates a primed driver and a compilation edited somewhere no request depends on.
+ /// The corpus size.
+ /// The edited compilation and a driver that has already generated once.
+ ///
+ /// This is the keystroke case: the consumer typed in a file that declares no event and calls no activation, so
+ /// a pipeline that caches properly should do nothing beyond re-scanning the one new tree.
+ ///
+ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateUnrelatedEditState(CorpusSize size)
+ {
+ var primed = RunOnce(size);
+ var edited = primed.Compilation.AddSyntaxTrees(
+ CSharpSyntaxTree.ParseText(EventCorpus.UnrelatedSource, ParseOptions, EventCorpus.UnrelatedFileName));
+ return (edited, primed.Driver);
+ }
+
+ /// Creates a primed driver and a compilation whose first host gained an event.
+ /// The corpus size.
+ /// The edited compilation and a driver that has already generated once.
+ ///
+ /// One host's file is replaced and no other. The edit changes what exactly one wrapper exposes, so the cost
+ /// here is the floor for a real change rather than for a full regeneration.
+ ///
+ internal static (Compilation Compilation, CSharpGeneratorDriver Driver) CreateEventEditState(CorpusSize size)
+ {
+ var primed = RunOnce(size);
+ var fileName = EventCorpus.HostFileName(EditedHostIndex);
+ var original = primed.Compilation.SyntaxTrees.First(tree =>
+ string.Equals(tree.FilePath, fileName, StringComparison.Ordinal));
+ var edited = primed.Compilation.ReplaceSyntaxTree(
+ original,
+ CSharpSyntaxTree.ParseText(
+ EventCorpus.HostSourceWithAddedEvent(EditedHostIndex),
+ ParseOptions,
+ fileName));
+ return (edited, primed.Driver);
+ }
+
+ /// Runs every corpus size once and reports what came out, so a broken corpus fails loudly.
+ /// A corpus does not compile, making its measurements worthless.
+ internal static void ValidateCorpus()
+ {
+ foreach (var size in Enum.GetValues())
+ {
+ var cold = CreateColdState(size);
+ var updated = cold.Driver.RunGeneratorsAndUpdateCompilation(cold.Compilation, out var result, out _);
+ var errors = result.GetDiagnostics()
+ .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)
+ .ToArray();
+ var generated = ((CSharpGeneratorDriver)updated).GetRunResult().GeneratedTrees.Length;
+
+ Console.WriteLine(string.Create(
+ CultureInfo.InvariantCulture,
+ $"{size}: {EventCorpus.HostCountFor(size)} hosts in {cold.Compilation.SyntaxTrees.Count()} files, "
+ + $"{generated} generated files, {errors.Length} errors"));
+
+ foreach (var error in errors)
+ {
+ Console.WriteLine(error.ToString());
+ }
+
+ if (errors.Length > 0)
+ {
+ throw new InvalidOperationException($"The {size} corpus does not compile; the benchmark is invalid.");
+ }
+ }
+ }
+
+ /// Builds a compilation over one file per host, as real code is laid out.
+ /// The corpus size.
+ /// The compilation.
+ private static CSharpCompilation BuildCompilation(CorpusSize size)
+ {
+ var files = EventCorpus.FilesFor(size);
+ var trees = new SyntaxTree[files.Count];
+ for (var index = 0; index < files.Count; index++)
+ {
+ trees[index] = CSharpSyntaxTree.ParseText(files[index].Text, ParseOptions, files[index].Path);
+ }
+
+ return CSharpCompilation.Create(CompilationAssemblyName, trees, CreateReferences(), CompilationOptions);
+ }
+
+ /// Creates a driver with only the observable-event generator loaded.
+ /// The driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static CSharpGeneratorDriver CreateDriver() =>
+ CSharpGeneratorDriver.Create(
+ [new EventGenerator().AsSourceGenerator()],
+ parseOptions: ParseOptions);
+
+ /// Runs the generator once so the driver has something cached to compare against.
+ /// The corpus size.
+ /// The compilation and the primed driver.
+ private static (Compilation Compilation, CSharpGeneratorDriver Driver) RunOnce(CorpusSize size)
+ {
+ var cold = CreateColdState(size);
+ return (cold.Compilation, (CSharpGeneratorDriver)cold.Driver.RunGenerators(cold.Compilation));
+ }
+
+ /// Collects the metadata references the corpus compiles against.
+ /// The metadata references, including the lean provider the generated wrappers name.
+ private static List CreateReferences() =>
+ [.. AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!
+ .Split(Path.PathSeparator)
+ .Select(static path => (MetadataReference)MetadataReference.CreateFromFile(path))];
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/Program.cs b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/Program.cs
new file mode 100644
index 00000000..0c179c82
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/Program.cs
@@ -0,0 +1,24 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using BenchmarkDotNet.Running;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Benchmarks;
+
+/// Entry point for the observable-event generator benchmarks.
+internal static class Program
+{
+ /// Runs a benchmark suite, or checks the corpus with --smoke.
+ /// BenchmarkDotNet command-line arguments.
+ internal static void Main(string[] args)
+ {
+ if (args.Contains("--smoke", StringComparer.OrdinalIgnoreCase))
+ {
+ GeneratorHarness.ValidateCorpus();
+ return;
+ }
+
+ _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+ }
+}
diff --git a/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks.csproj b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks.csproj
new file mode 100644
index 00000000..07c59378
--- /dev/null
+++ b/src/benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks/ReactiveUI.Primitives.ObservableEvents.Benchmarks.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0;net11.0
+ Exe
+ false
+ true
+ true
+ $(NoWarn);CA1822
+
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs
new file mode 100644
index 00000000..b2e0dacc
--- /dev/null
+++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/DiagnosticInfoTests.cs
@@ -0,0 +1,64 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Tests;
+
+/// Verifies the diagnostics a model carries out of extraction and back into a report.
+public sealed class DiagnosticInfoTests
+{
+ /// The host name the diagnostics under test are reported against.
+ private const string HostDisplayName = "Samples.EventSource";
+
+ /// The source location the reported diagnostics point into.
+ private const string SamplePath = "Sample.cs";
+
+ /// Verifies a single-argument diagnostic keeps its message and its location.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task DiagnosticInfoRebuildsASingleArgumentDiagnostic()
+ {
+ var tree = CSharpSyntaxTree.ParseText("class Sample { }", path: SamplePath);
+ var location = LocationInfo.From((await tree.GetRootAsync()).GetLocation());
+
+ var diagnostic = DiagnosticInfo
+ .Create(DiagnosticWarnings.MissingProvider, location, HostDisplayName)
+ .ToDiagnostic();
+
+ await Assert.That(diagnostic.Id).IsEqualTo("RXOE001");
+ await Assert.That(diagnostic.GetMessage()).Contains(HostDisplayName);
+ await Assert.That(diagnostic.Location.GetLineSpan().Path).IsEqualTo(SamplePath);
+ }
+
+ /// Verifies a two-argument diagnostic fills both placeholders.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task DiagnosticInfoRebuildsATwoArgumentDiagnostic()
+ {
+ var diagnostic = new DiagnosticInfo(
+ DiagnosticWarnings.NoEvents,
+ null,
+ DiagnosticWarnings.StaticHostKind,
+ HostDisplayName).ToDiagnostic();
+
+ await Assert.That(diagnostic.Id).IsEqualTo("RXOE002");
+ await Assert.That(diagnostic.GetMessage()).Contains("static");
+ await Assert.That(diagnostic.GetMessage()).Contains(HostDisplayName);
+ }
+
+ /// Verifies a diagnostic with nowhere to point still reports rather than throwing.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task DiagnosticInfoReportsWithoutALocation()
+ {
+ var diagnostic = DiagnosticInfo
+ .Create(DiagnosticWarnings.MissingProvider, null, HostDisplayName)
+ .ToDiagnostic();
+
+ await Assert.That(diagnostic.Location).IsEqualTo(Location.None);
+ }
+}
diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EquatableArrayTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EquatableArrayTests.cs
new file mode 100644
index 00000000..54c3888f
--- /dev/null
+++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EquatableArrayTests.cs
@@ -0,0 +1,85 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using ReactiveUI.Primitives.ObservableEvents.Models;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Tests;
+
+/// Verifies the value equality that the generator's per-target caching is built on.
+public sealed class EquatableArrayTests
+{
+ /// Verifies a defaulted array reports itself empty and equal to another defaulted one.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayTreatsDefaultAsEmpty()
+ {
+ var first = EquatableArray.Empty;
+ var second = default(EquatableArray);
+
+ await Assert.That(first.IsEmpty).IsTrue();
+ await Assert.That(first.AsArray()).IsEmpty();
+ await Assert.That(first == second).IsTrue();
+ await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode());
+ }
+
+ /// Verifies an array wrapping no elements matches a defaulted one, hash included.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayMatchesDefaultWhenWrappingNoElements()
+ {
+ var wrapped = new EquatableArray([]);
+
+ await Assert.That(wrapped.IsEmpty).IsTrue();
+ await Assert.That(wrapped == EquatableArray.Empty).IsTrue();
+ await Assert.That(wrapped.GetHashCode()).IsEqualTo(EquatableArray.Empty.GetHashCode());
+ }
+
+ /// Verifies equal contents in separate arrays compare equal, which is what keeps a step cached.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayComparesEqualContentsAcrossSeparateArrays()
+ {
+ var first = new EquatableArray(["a", "b"]);
+ var second = new EquatableArray(["a", "b"]);
+
+ await Assert.That(first == second).IsTrue();
+ await Assert.That(first != second).IsFalse();
+ await Assert.That(first.Equals((object)second)).IsTrue();
+ await Assert.That(first.GetHashCode()).IsEqualTo(second.GetHashCode());
+ }
+
+ /// Verifies a differing element, a differing length, and an empty counterpart all compare unequal.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayComparesUnequalWhenContentsDiffer()
+ {
+ var reference = new EquatableArray(["a", "b"]);
+
+ await Assert.That(reference != new EquatableArray(["a", "c"])).IsTrue();
+ await Assert.That(reference != new EquatableArray(["a"])).IsTrue();
+ await Assert.That(reference != EquatableArray.Empty).IsTrue();
+ await Assert.That(EquatableArray.Empty != reference).IsTrue();
+ }
+
+ /// Verifies comparing against an unrelated object is unequal rather than throwing.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayComparesUnequalToAnUnrelatedObject()
+ {
+ var array = new EquatableArray(["a"]);
+
+ await Assert.That(array.Equals("a")).IsFalse();
+ }
+
+ /// Verifies the wrapped elements come back out in order for the emitters to walk.
+ /// A task representing the asynchronous operation.
+ [Test]
+ public async Task EquatableArrayReturnsItsElementsInOrder()
+ {
+ var array = new EquatableArray(["first", "second"]);
+
+ await Assert.That(array.IsEmpty).IsFalse();
+ await Assert.That(string.Join(",", array.AsArray())).IsEqualTo("first,second");
+ }
+}
diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs
new file mode 100644
index 00000000..1ee3bc75
--- /dev/null
+++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Incremental.cs
@@ -0,0 +1,363 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using System.Diagnostics.CodeAnalysis;
+using System.Runtime.CompilerServices;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+
+namespace ReactiveUI.Primitives.ObservableEvents.Tests;
+
+/// Verifies the generator's pipeline caches, so an unrelated edit costs nothing to recompute.
+///
+/// Correct output alone does not prove a generator is incremental: a pipeline that reruns everything on every
+/// keystroke produces exactly the same files. What proves it is the driver's own record of why each step ran, which
+/// is what these tests read. They are the guard against a model quietly regaining a symbol, a syntax node, or the
+/// compilation, any of which makes every step compare unequal and every file regenerate on every keystroke.
+///
+public sealed partial class EventGeneratorTests
+{
+ /// Consumer source that exercises both the instance and the static request routes at once.
+ private const string IncrementalSource = """
+ using System;
+ using ReactiveUI.Primitives.ObservableEvents;
+
+ [assembly: GenerateStaticEventObservables(typeof(Samples.EventSource))]
+
+ namespace Samples;
+
+ public sealed class EventSource
+ {
+ public event EventHandler? Changed;
+
+ public static event Action? GlobalChanged;
+ }
+
+ public static class Consumer
+ {
+ public static IObservable Observe(EventSource source) => source.Events().Changed;
+ }
+ """;
+
+ /// Source in a second file that no request depends on.
+ private const string UnrelatedSource = """
+ namespace Samples;
+
+ public static class Unrelated
+ {
+ public static int Value => 1;
+ }
+ """;
+
+ /// The same source after an edit that changes nothing any request depends on.
+ private const string EditedUnrelatedSource = """
+ namespace Samples;
+
+ public static class Unrelated
+ {
+ public static int Value => 2;
+
+ public static string Name => "added";
+ }
+ """;
+
+ /// The event declaration the incremental-change test adds an event alongside.
+ private const string ExistingEventDeclaration = "public event EventHandler? Changed;";
+
+ /// The same declaration with a second event added after it.
+ private const string AddedEventDeclaration =
+ "public event EventHandler? Changed;\n\n public event Action? Added;";
+
+ /// The pipeline steps whose caching these tests assert.
+ private static readonly string[] TrackedStepNames =
+ [
+ GeneratorStepNames.Provider,
+ GeneratorStepNames.InstanceTargets,
+ GeneratorStepNames.StaticTargets,
+ GeneratorStepNames.ActivationOverloads,
+ GeneratorStepNames.StaticNamespaces,
+ ];
+
+ /// The steps that must not recompute at all when an unrelated file is edited.
+ ///
+ /// These are the two that run the semantic model, and they are the expensive half of the generator. Accepting
+ /// Unchanged here would let a regression through: a transform that re-runs and happens to produce an
+ /// equal value still paid for every symbol walk, which is the cost this pipeline exists to avoid.
+ ///
+ private static readonly string[] SemanticStepNames =
+ [
+ GeneratorStepNames.InstanceTargets,
+ GeneratorStepNames.StaticTargets,
+ ];
+
+ /// The Roslyn types a pipeline model must never carry, because they defeat the caching.
+ private static readonly Type[] UncacheableTypes =
+ [
+ typeof(ISymbol),
+ typeof(SyntaxNode),
+ typeof(Compilation),
+ typeof(SemanticModel),
+ typeof(SyntaxTree),
+ typeof(Location),
+ ];
+
+ /// Gets the parse options every compilation in these tests uses.
+ private static CSharpParseOptions IncrementalParseOptions =>
+ CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview);
+
+ /// Verifies a second run over an equivalent compilation recomputes no step's value.
+ /// A task representing the asynchronous operation.
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorCachesEveryPipelineStepWhenNothingChanges()
+ {
+ var compilation = CreateCompilation([IncrementalSource]);
+ GeneratorDriver driver = CreateTrackingDriver();
+ driver = driver.RunGenerators(compilation);
+
+ // A clone is a different compilation object holding the same trees, so every step is asked again and
+ // every step has to answer that its value is unchanged.
+ driver = driver.RunGenerators(compilation.Clone());
+ var reasons = CollectTrackedStepReasons(driver.GetRunResult());
+
+ await Assert.That(reasons).IsNotEmpty();
+ await Assert.That(reasons.FindAll(static reason => !IsCached(reason.Reason))).IsEmpty();
+ }
+
+ /// Verifies editing a file no request depends on leaves every requested host's model equal.
+ /// A task representing the asynchronous operation.
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorCachesPipelineStepsAcrossAnUnrelatedEdit()
+ {
+ var compilation = CreateCompilation([IncrementalSource, UnrelatedSource]);
+ GeneratorDriver driver = CreateTrackingDriver();
+ driver = driver.RunGenerators(compilation);
+ var firstSources = CollectGeneratedSources(driver.GetRunResult());
+
+ var edited = compilation.ReplaceSyntaxTree(
+ compilation.SyntaxTrees[^1],
+ ParseSource(EditedUnrelatedSource));
+ driver = driver.RunGenerators(edited);
+ var runResult = driver.GetRunResult();
+ var reasons = CollectTrackedStepReasons(runResult);
+
+ await Assert.That(reasons).IsNotEmpty();
+ await Assert.That(reasons.FindAll(static reason => !IsCached(reason.Reason))).IsEmpty();
+ await Assert.That(CollectGeneratedSources(runResult)).IsEqualTo(firstSources);
+
+ // The semantic transforms must have been skipped outright, not re-run to an equal answer.
+ var semantic = reasons.FindAll(static reason => Array.Exists(
+ SemanticStepNames,
+ name => name == reason.StepName));
+ await Assert.That(semantic).IsNotEmpty();
+ await Assert.That(semantic.FindAll(
+ static reason => reason.Reason != IncrementalStepRunReason.Cached)).IsEmpty();
+ }
+
+ /// Verifies the generator emits no post-initialization output, which would defeat all caching.
+ /// A task representing the asynchronous operation.
+ ///
+ /// Post-initialization source is added to the compilation the pipeline runs against, so producing any at all -
+ /// even one file nothing refers to - makes that compilation new on every run and discards every semantic result
+ /// cached against the previous one. Measured at roughly a hundredfold on an unchanged re-run, so this is worth
+ /// a test of its own: the activation API has to arrive as an ordinary source output instead.
+ ///
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorEmitsNoPostInitializationOutput()
+ {
+ // Ordinary source output is switched off, so anything left is post-initialization output.
+ var driver = CSharpGeneratorDriver.Create(
+ [new EventGenerator().AsSourceGenerator()],
+ parseOptions: IncrementalParseOptions,
+ driverOptions: new(IncrementalGeneratorOutputKind.Source, trackIncrementalGeneratorSteps: false));
+
+ var runResult = driver.RunGenerators(CreateCompilation([IncrementalSource])).GetRunResult();
+
+ await Assert.That(runResult.GeneratedTrees).IsEmpty();
+ }
+
+ /// Verifies re-running against the very same compilation recomputes nothing at all.
+ /// A task representing the asynchronous operation.
+ ///
+ /// The strongest statement the pipeline can make, and the one that only holds while no post-initialization
+ /// output exists: handed back the identical compilation, every semantic transform is skipped outright.
+ ///
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorRecomputesNothingForTheSameCompilation()
+ {
+ var compilation = CreateCompilation([IncrementalSource]);
+ var primed = CreateTrackingDriver().RunGenerators(compilation);
+
+ var reasons = CollectTrackedStepReasons(primed.RunGenerators(compilation).GetRunResult());
+
+ var semantic = reasons.FindAll(static reason => Array.Exists(
+ SemanticStepNames,
+ name => name == reason.StepName));
+ await Assert.That(semantic).IsNotEmpty();
+ await Assert.That(semantic.FindAll(
+ static reason => reason.Reason != IncrementalStepRunReason.Cached)).IsEmpty();
+ }
+
+ /// Verifies no step's value carries a Roslyn object that would defeat the caching.
+ /// A task representing the asynchronous operation.
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorPipelineStepsCarryNoRoslynObjects()
+ {
+ GeneratorDriver driver = CreateTrackingDriver();
+ driver = driver.RunGenerators(CreateCompilation([IncrementalSource]));
+
+ var leaked = CollectTrackedStepValues(driver.GetRunResult())
+ .FindAll(static value => Array.Exists(UncacheableTypes, type => type.IsInstanceOfType(value)));
+
+ await Assert.That(leaked).IsEmpty();
+ }
+
+ /// Verifies a change to a requested host's events re-emits only what that change affects.
+ /// A task representing the asynchronous operation.
+ [Test]
+ [RequiresAssemblyFiles]
+ public async Task EventGeneratorRecomputesOnlyTheStepsAnEventChangeAffects()
+ {
+ var compilation = CreateCompilation([IncrementalSource]);
+ GeneratorDriver driver = CreateTrackingDriver();
+ driver = driver.RunGenerators(compilation);
+
+ var edited = compilation.ReplaceSyntaxTree(
+ compilation.SyntaxTrees[0],
+ ParseSource(IncrementalSource.Replace(
+ ExistingEventDeclaration,
+ AddedEventDeclaration,
+ StringComparison.Ordinal)));
+ driver = driver.RunGenerators(edited);
+ var reasons = CollectTrackedStepReasons(driver.GetRunResult());
+
+ // The added event changes what the wrapper exposes, so the host's own model has to be recomputed.
+ await Assert.That(reasons.Exists(static reason =>
+ reason.StepName == GeneratorStepNames.InstanceTargets && !IsCached(reason.Reason))).IsTrue();
+
+ // Its signature did not move, so the shared overload file must not be rebuilt, and neither the static
+ // request nor the resolved provider has anything to do with an instance event being added.
+ await Assert.That(reasons.FindAll(static reason =>
+ reason.StepName != GeneratorStepNames.InstanceTargets && !IsCached(reason.Reason)))
+ .IsEmpty();
+ }
+
+ /// Creates a driver that records why each tracked step ran.
+ /// The step-tracking driver.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static CSharpGeneratorDriver CreateTrackingDriver() =>
+ CSharpGeneratorDriver.Create(
+ [new EventGenerator().AsSourceGenerator()],
+ parseOptions: IncrementalParseOptions,
+ driverOptions: new(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));
+
+ /// Creates a consumer compilation referencing the lean provider.
+ /// The consumer source files.
+ /// The consumer compilation.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ [RequiresAssemblyFiles("Calls System.Reflection.Assembly.Location")]
+ private static CSharpCompilation CreateCompilation(string[] sources) =>
+ CSharpCompilation.Create(
+ "ObservableEventsIncremental",
+ Array.ConvertAll(sources, ParseSource),
+ CreateReferences(ProviderMode.Lean, []),
+ new(OutputKind.DynamicallyLinkedLibrary));
+
+ /// Parses one consumer source file.
+ /// The source text.
+ /// The parsed tree.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static SyntaxTree ParseSource(string source) =>
+ CSharpSyntaxTree.ParseText(source, IncrementalParseOptions);
+
+ /// Collects why each tracked step produced each of its values.
+ /// The driver run result.
+ /// One entry per tracked step output.
+ private static List CollectTrackedStepReasons(GeneratorDriverRunResult runResult)
+ {
+ var reasons = new List();
+ foreach (var result in runResult.Results)
+ {
+ foreach (var tracked in result.TrackedSteps)
+ {
+ if (!Array.Exists(TrackedStepNames, name => name == tracked.Key))
+ {
+ continue;
+ }
+
+ foreach (var step in tracked.Value)
+ {
+ foreach (var output in step.Outputs)
+ {
+ reasons.Add(new(tracked.Key, output.Reason));
+ }
+ }
+ }
+ }
+
+ return reasons;
+ }
+
+ /// Collects the value every tracked step produced.
+ /// The driver run result.
+ /// The tracked step values.
+ private static List