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 @@ true Analyzer false + + false Incremental 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 CollectTrackedStepValues(GeneratorDriverRunResult runResult) + { + var values = 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) + { + values.Add(output.Value); + } + } + } + } + + return values; + } + + /// Renders every generated file as one comparable string, so two runs can be compared. + /// The driver run result. + /// The generated files, in hint-name order. + private static string CollectGeneratedSources(GeneratorDriverRunResult runResult) + { + var entries = new List(); + foreach (var result in runResult.Results) + { + foreach (var generated in result.GeneratedSources) + { + entries.Add($"{generated.HintName}\n{generated.SourceText}"); + } + } + + entries.Sort(StringComparer.Ordinal); + return string.Join("\n", entries); + } + + /// Determines whether a step reused its previous value rather than producing a new one. + /// The reason the driver recorded. + /// when nothing downstream had to be recomputed. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsCached(IncrementalStepRunReason reason) => + reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged; + + /// Why one tracked step produced one of its values. + /// The tracked step name. + /// The reason the driver recorded. + private sealed record TrackedStepReason(string StepName, IncrementalStepRunReason Reason); +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs new file mode 100644 index 00000000..0b95e7cf --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.Nullability.cs @@ -0,0 +1,142 @@ +// 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 System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies generated handlers match the nullability of the delegates they are assigned to. +/// +/// A handler whose parameter nullability differs from its delegate's is assignable but warns (CS8622), and a +/// consumer building warnings-as-errors cannot use the generated wrapper at all. The annotations are therefore part +/// of the contract - but only where the consumer's language version can express them, which is what these pin down. +/// +public sealed partial class EventGeneratorTests +{ + /// The nullable directive a generated file opens with when the consumer's language allows it. + private const string NullableDirective = "#nullable enable"; + + /// The annotated sender the conventional event delegate declares. + private const string AnnotatedSenderHandler = "void Handler(object? sender, global::System.EventArgs e)"; + + /// The same handler with the annotation dropped, as an older language version requires. + private const string ObliviousSenderHandler = "void Handler(object sender, global::System.EventArgs e)"; + + /// Consumer source valid on every language version the generator supports. + /// + /// Requests both an instance wrapper and a static one, so every generated file the language gate touches - the + /// wrapper, the shared overloads, and the namespace's static class - is exercised at both language versions. + /// + private const string ConventionalEventSource = """ + using System; + using ReactiveUI.Primitives.ObservableEvents; + + [assembly: GenerateStaticEventObservables(typeof(Samples.StaticHost))] + + namespace Samples + { + public sealed class EventSource + { + public event EventHandler Changed; + + public void Raise() => Changed?.Invoke(this, EventArgs.Empty); + } + + public static class StaticHost + { + public static event EventHandler GlobalChanged; + } + + public static class Consumer + { + public static IObservable Observe(EventSource source) => source.Events().Changed; + } + } + """; + + /// Verifies a modern consumer gets a handler matching the delegate's annotated sender. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorAnnotatesHandlersWhenTheLanguageSupportsIt() + { + var result = RunGeneratorAt(ConventionalEventSource, LanguageVersion.Preview); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(result.GeneratedText).Contains(AnnotatedSenderHandler); + await Assert.That(result.GeneratedText).Contains(NullableDirective); + } + + /// Verifies the generated wrapper raises no nullability mismatch against the delegate it wraps. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorProducesNoNullabilityMismatchWarnings() + { + var result = RunGeneratorAt(ConventionalEventSource, LanguageVersion.Preview, NullableContextOptions.Enable); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(result.CompilationWarnings.Where(static id => id == "CS8622")).IsEmpty(); + } + + /// Verifies a consumer predating nullable reference types gets source it can actually compile. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorOmitsAnnotationsWhenTheLanguagePredatesThem() + { + var result = RunGeneratorAt(ConventionalEventSource, LanguageVersion.CSharp7_3); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(result.GeneratedText).Contains(ObliviousSenderHandler); + await Assert.That(result.GeneratedText).DoesNotContain(NullableDirective); + await Assert.That(result.GeneratedText).DoesNotContain("object?"); + } + + /// Runs the generator for one source at a chosen language version. + /// The consumer source to compile. + /// The language version the consumer is compiled at. + /// The consumer's nullable context. + /// The compile errors, warning identifiers, and generated source. + [RequiresAssemblyFiles("Calls System.Reflection.Assembly.Location")] + private static LanguageVersionResult RunGeneratorAt( + string source, + LanguageVersion languageVersion, + NullableContextOptions nullableContext = NullableContextOptions.Disable) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(languageVersion); + var compilation = CSharpCompilation.Create( + $"ObservableEventsNullability_{languageVersion}", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + CreateReferences(ProviderMode.Lean, []), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + .WithNullableContextOptions(nullableContext)); + var driver = CSharpGeneratorDriver.Create( + [new EventGenerator().AsSourceGenerator()], + parseOptions: parseOptions); + _ = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _); + + var diagnostics = updated.GetDiagnostics(); + return new( + [.. diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)], + [.. diagnostics + .Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Warning) + .Select(static diagnostic => diagnostic.Id)], + string.Join( + Environment.NewLine, + updated.SyntaxTrees.Select(static tree => tree.ToString()))); + } + + /// What one language-version run produced. + /// The compile errors after generation. + /// The identifiers of every warning the consumer would see. + /// Every tree in the updated compilation, generated ones included. + private sealed record LanguageVersionResult( + ImmutableArray Errors, + ImmutableArray CompilationWarnings, + string GeneratedText); +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs new file mode 100644 index 00000000..7b39a6fd --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/EventGeneratorTests.StaticRequests.cs @@ -0,0 +1,117 @@ +// 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; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies every spelling of the static request attribute is recognised. +/// +/// The attribute is matched on how it is written rather than on the symbol it binds to, because the type it names +/// is this generator's own output and output is invisible to the pipeline that produced it. That puts the burden of +/// understanding C#'s naming forms on this generator, so each one a consumer may reasonably write is pinned here. +/// +public sealed partial class EventGeneratorTests +{ + /// The host every spelling in these tests points at. + private const string StaticRequestHost = """ + + namespace Samples + { + public static class EventSource + { + public static event System.Action? GlobalChanged; + } + } + """; + + /// Verifies the attribute written out in full is recognised. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorAcceptsAFullyQualifiedStaticRequest() + { + const string Source = """ + [assembly: ReactiveUI.Primitives.ObservableEvents.GenerateStaticEventObservables( + typeof(Samples.EventSource))] + """ + StaticRequestHost; + + var result = RunGenerator(Source, ProviderMode.Lean); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(Array.Exists(result.GeneratedHintNames, IsStaticHint)).IsTrue(); + } + + /// Verifies the attribute written with the suffix the language lets you omit is recognised. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorAcceptsTheSuffixedStaticRequestSpelling() + { + const string Source = """ + using ReactiveUI.Primitives.ObservableEvents; + + [assembly: GenerateStaticEventObservablesAttribute(typeof(Samples.EventSource))] + """ + StaticRequestHost; + + var result = RunGenerator(Source, ProviderMode.Lean); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(Array.Exists(result.GeneratedHintNames, IsStaticHint)).IsTrue(); + } + + /// Verifies the attribute reached through a namespace alias is recognised. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorAcceptsAnAliasQualifiedStaticRequest() + { + const string Source = """ + using Events = ReactiveUI.Primitives.ObservableEvents; + + [assembly: Events::GenerateStaticEventObservables(typeof(Samples.EventSource))] + """ + StaticRequestHost; + + var result = RunGenerator(Source, ProviderMode.Lean); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(Array.Exists(result.GeneratedHintNames, IsStaticHint)).IsTrue(); + } + + /// Verifies an unrelated generic assembly attribute is ignored rather than mistaken for a request. + /// A task representing the asynchronous operation. + [Test] + [RequiresAssemblyFiles] + public async Task EventGeneratorIgnoresAGenericAssemblyAttribute() + { + const string Source = """ + [assembly: Samples.Tagged] + + namespace Samples + { + public sealed class TaggedAttribute : System.Attribute + { + } + + public static class EventSource + { + public static event System.Action? GlobalChanged; + } + } + """; + + var result = RunGenerator(Source, ProviderMode.Lean); + + await Assert.That(result.Errors).IsEmpty(); + await Assert.That(result.GeneratedHintNames.Any(IsEventWrapperHint)).IsFalse(); + } + + /// Determines whether a generated-source hint identifies a static wrapper. + /// The hint name. + /// for a static wrapper hint. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsStaticHint(string hintName) => + hintName.EndsWith(StaticHintSuffix, StringComparison.Ordinal); +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs new file mode 100644 index 00000000..c76142ea --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/GeneratedNamesTests.cs @@ -0,0 +1,75 @@ +// 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.CodeGeneration; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies the naming that keeps generated wrappers and files from colliding. +public sealed class GeneratedNamesTests +{ + /// Verifies punctuation is folded to underscores and a hash keeps the identity. + /// A task representing the asynchronous operation. + [Test] + public async Task GeneratedNamesFoldsPunctuationAndKeepsAHash() + { + var name = GeneratedNames.WrapperName("global::Samples.EventSource"); + + await Assert.That(name).StartsWith("Rxglobal__Samples_EventSource_"); + await Assert.That(name).EndsWith("Events"); + } + + /// Verifies leading and trailing punctuation is dropped rather than left as underscores. + /// A task representing the asynchronous operation. + [Test] + public async Task GeneratedNamesTrimsLeadingAndTrailingPunctuation() + { + var trimmed = GeneratedNames.WrapperName("::Samples.EventSource."); + var bare = GeneratedNames.WrapperName("Samples.EventSource"); + + await Assert.That(trimmed).StartsWith("RxSamples_EventSource_"); + + // Same readable component, different identity: only the hash may distinguish them. + await Assert.That(trimmed).IsNotEqualTo(bare); + } + + /// Verifies an identity with nothing nameable still produces a usable identifier. + /// A task representing the asynchronous operation. + [Test] + public async Task GeneratedNamesHandlesAnIdentityWithNothingNameable() + { + var empty = GeneratedNames.StaticHintName(string.Empty); + var punctuationOnly = GeneratedNames.StaticHintName("::"); + + await Assert.That(empty).StartsWith("ObservableEvents._"); + await Assert.That(empty).EndsWith(".Static.g.cs"); + await Assert.That(punctuationOnly).StartsWith("ObservableEvents._"); + await Assert.That(punctuationOnly).IsNotEqualTo(empty); + } + + /// Verifies sanitized-alike identities keep distinct file names, which duplicate hints would break. + /// A task representing the asynchronous operation. + [Test] + public async Task GeneratedNamesKeepsSanitizedAlikeIdentitiesApart() + { + var first = GeneratedNames.InstanceHintName("global::Samples.A_B.C"); + var second = GeneratedNames.InstanceHintName("global::Samples.A.B_C"); + + await Assert.That(first).IsNotEqualTo(second); + await Assert.That(first).EndsWith(".Instance.g.cs"); + await Assert.That(second).EndsWith(".Instance.g.cs"); + } + + /// Verifies the same identity always produces the same name, so rebuilds stay deterministic. + /// A task representing the asynchronous operation. + [Test] + public async Task GeneratedNamesAreDeterministic() + { + const string Identity = "global::Samples.EventSource"; + + await Assert.That(GeneratedNames.InstanceHintName(Identity)) + .IsEqualTo(GeneratedNames.InstanceHintName(Identity)); + await Assert.That(GeneratedNames.WrapperName(Identity)).IsEqualTo(GeneratedNames.WrapperName(Identity)); + } +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/LocationInfoTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/LocationInfoTests.cs new file mode 100644 index 00000000..35610788 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/LocationInfoTests.cs @@ -0,0 +1,80 @@ +// 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.CSharp; +using ReactiveUI.Primitives.ObservableEvents.Models; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies the reduced location a diagnostic carries through the pipeline. +public sealed class LocationInfoTests +{ + /// The source every compilation in these tests is built from. + private const string SampleSource = "class Sample { }"; + + /// The path that source is attributed to. + private const string SamplePath = "Sample.cs"; + + /// Verifies a source location survives the round trip to values and back. + /// A task representing the asynchronous operation. + [Test] + public async Task LocationInfoRoundTripsASourceLocation() + { + var original = (await ParseSampleAsync()).GetLocation(); + + var info = LocationInfo.From(original); + + await Assert.That(info).IsNotNull(); + var rebuilt = info!.ToLocation(); + await Assert.That(rebuilt.SourceSpan).IsEqualTo(original.SourceSpan); + await Assert.That(rebuilt.GetLineSpan().Path).IsEqualTo(original.GetLineSpan().Path); + await Assert.That(rebuilt.GetLineSpan().Span).IsEqualTo(original.GetLineSpan().Span); + } + + /// Verifies a location with no source is reduced to nothing rather than a bogus span. + /// A task representing the asynchronous operation. + [Test] + public async Task LocationInfoDiscardsALocationWithNoSource() + { + await Assert.That(LocationInfo.From(null)).IsNull(); + await Assert.That(LocationInfo.From(Location.None)).IsNull(); + } + + /// Verifies a syntax reference is reduced through the same path as a bare location. + /// A task representing the asynchronous operation. + [Test] + public async Task LocationInfoReducesASyntaxReference() + { + var reference = (await ParseSampleAsync()).GetReference(); + + var info = LocationInfo.From(reference, CancellationToken.None); + + await Assert.That(info).IsNotNull(); + await Assert.That(info!.FilePath).IsEqualTo(SamplePath); + } + + /// Verifies an absent reference, as an attribute read from metadata has, reduces to nothing. + /// A task representing the asynchronous operation. + [Test] + public async Task LocationInfoDiscardsAnAbsentSyntaxReference() => + await Assert.That(LocationInfo.From(null, CancellationToken.None)).IsNull(); + + /// Verifies two reductions of the same location compare equal, so a model stays cached. + /// A task representing the asynchronous operation. + [Test] + public async Task LocationInfoComparesByValue() + { + var location = (await ParseSampleAsync()).GetLocation(); + + await Assert.That(LocationInfo.From(location)).IsEqualTo(LocationInfo.From(location)); + } + + /// Parses the shared sample source. + /// The parsed root node. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Task ParseSampleAsync() => + CSharpSyntaxTree.ParseText(SampleSource, path: SamplePath).GetRootAsync(); +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs new file mode 100644 index 00000000..fef5a047 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/PooledStringBuilderTests.cs @@ -0,0 +1,198 @@ +// 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.CodeGeneration; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies the pooled builder the emitters accumulate generated source into. +public sealed class PooledStringBuilderTests +{ + /// The number of buffers the thread-local free list keeps. + private const int PoolCapacity = 16; + + /// The number of buffers returned when overflowing the free list. + private const int OverflowingReturnCount = PoolCapacity + 4; + + /// A capacity below the builder's own minimum, to exercise the floor. + private const int BelowMinimumCapacity = 8; + + /// A capacity that forces the buffer to grow more than once. + private const int GrowthTriggerLength = 4096; + + /// A length just past the default capacity, so doubling covers it in one step. + private const int SmallOverflowLength = 300; + + /// A single-digit value, the common case when rendering a name length. + private const int SingleDigitValue = 7; + + /// A ten-digit value, the widest rendering short of the maximum. + private const int TenDigitValue = 1_234_567_890; + + /// The indentation used by the block-indenting assertions. + private const int SampleIndent = 2; + + /// One level of the indentation the emitters actually use. + private const int MemberIndent = 4; + + /// Verifies a fresh builder renders as an empty string. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderRendersEmptyBeforeAnythingIsAppended() + { + var builder = new PooledStringBuilder(BelowMinimumCapacity); + + await Assert.That(builder.Length).IsEqualTo(0); + await Assert.That(builder.ToStringAndReturn()).IsEqualTo(string.Empty); + } + + /// Verifies null and empty appends leave the builder untouched. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderIgnoresNullAndEmptyAppends() + { + var builder = new PooledStringBuilder(); + + _ = builder.Append((string?)null).Append(string.Empty).Append("kept"); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo("kept"); + } + + /// Verifies integers render in invariant decimal, including the multi-digit and zero cases. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderRendersIntegersInDecimal() + { + var builder = new PooledStringBuilder(); + + _ = builder.Append(0).Append('|').Append(SingleDigitValue).Append('|').Append(TenDigitValue) + .Append('|').Append(int.MaxValue); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo("0|7|1234567890|2147483647"); + } + + /// Verifies appending past the rented capacity grows the buffer without losing content. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderGrowsWithoutLosingContent() + { + var builder = new PooledStringBuilder(BelowMinimumCapacity); + var expected = new string('x', GrowthTriggerLength); + + _ = builder.Append(expected).Append(expected); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo(expected + expected); + } + + /// Verifies a small overflow grows by the doubling factor rather than to the exact size asked for. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderGrowsByDoublingForASmallOverflow() + { + var builder = new PooledStringBuilder(BelowMinimumCapacity); + var expected = new string('y', SmallOverflowLength); + + _ = builder.Append(expected); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo(expected); + } + + /// Verifies a nested fragment builder's content joins its parent and is drained. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderDrainsAnAppendedFragmentBuilder() + { + var builder = new PooledStringBuilder(); + var fragment = new PooledStringBuilder().Append("fragment"); + var empty = new PooledStringBuilder(); + + _ = builder.Append(fragment).Append(empty); + + await Assert.That(fragment.Length).IsEqualTo(0); + await Assert.That(builder.ToStringAndReturn()).IsEqualTo("fragment"); + } + + /// Verifies returning a builder twice is harmless, so a drained fragment can be released again. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderToleratesBeingReturnedTwice() + { + var builder = new PooledStringBuilder().Append("value"); + + builder.Return(); + builder.Return(); + + await Assert.That(builder.Length).IsEqualTo(0); + } + + /// Verifies buffers past the free list's capacity are dropped rather than retained. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderDropsBuffersBeyondTheFreeListCapacity() + { + var builders = new PooledStringBuilder[OverflowingReturnCount]; + for (var index = 0; index < builders.Length; index++) + { + builders[index] = new PooledStringBuilder().Append(index); + } + + foreach (var builder in builders) + { + builder.Return(); + } + + // The pool is now full; a fresh builder still has to work, whether it was handed a pooled buffer or a + // newly allocated one. + await Assert.That(new PooledStringBuilder().Append("after").ToStringAndReturn()).IsEqualTo("after"); + } + + /// Verifies indentation is applied per line and never lands on a blank one. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderIndentsEveryNonEmptyLine() + { + var builder = new PooledStringBuilder(); + + _ = builder.AppendIndentedLines("first\n\nthird\n", SampleIndent); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo(" first\n\n third\n"); + } + + /// Verifies a block whose last line has no terminator still gets one. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderTerminatesAnUnterminatedFinalLine() + { + var builder = new PooledStringBuilder(); + + _ = builder.AppendIndentedLines("only", MemberIndent); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo(" only\n"); + } + + /// Verifies an empty block contributes nothing, which is how an unconstrained host emits. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderWritesNothingForAnEmptyBlock() + { + var builder = new PooledStringBuilder(); + + _ = builder.AppendIndentedLines(string.Empty, MemberIndent); + + await Assert.That(builder.Length).IsEqualTo(0); + builder.Return(); + } + + /// Verifies the line-terminator overloads append the fixed newline. + /// A task representing the asynchronous operation. + [Test] + public async Task PooledStringBuilderAppendsFixedLineTerminators() + { + var builder = new PooledStringBuilder(); + + _ = builder.AppendLine("line").AppendLine(); + + await Assert.That(builder.ToStringAndReturn()).IsEqualTo("line\n\n"); + } +} diff --git a/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/SymbolHelpersTests.cs b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/SymbolHelpersTests.cs new file mode 100644 index 00000000..474da995 --- /dev/null +++ b/src/tests/ReactiveUI.Primitives.ObservableEvents.Tests/SymbolHelpersTests.cs @@ -0,0 +1,57 @@ +// 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; + +namespace ReactiveUI.Primitives.ObservableEvents.Tests; + +/// Verifies the source fragments symbols are rendered into. +public sealed class SymbolHelpersTests +{ + /// Verifies an identifier that collides with a keyword is escaped. + /// A task representing the asynchronous operation. + [Test] + public async Task SymbolHelpersEscapesKeywordIdentifiers() + { + await Assert.That(SymbolHelpers.EscapeIdentifier("event")).IsEqualTo("@event"); + await Assert.That(SymbolHelpers.EscapeIdentifier("value")).IsEqualTo("value"); + } + + /// Verifies text with nothing to escape comes back as the same instance. + /// A task representing the asynchronous operation. + [Test] + public async Task SymbolHelpersLeavesPlainDocumentationTextAlone() + { + const string Plain = "global::Samples.EventSource"; + + await Assert.That(SymbolHelpers.EscapeXml(Plain)).IsSameReferenceAs(Plain); + } + + /// Verifies every character that would break a documentation comment is escaped. + /// A task representing the asynchronous operation. + [Test] + public async Task SymbolHelpersEscapesEveryDocumentationSpecialCharacter() => + await Assert.That(SymbolHelpers.EscapeXml("A&B\"C'D")) + .IsEqualTo("A<T>&B"C'D"); + + /// Verifies each special character is recognised wherever it happens to appear first. + /// Text whose leading character is the one under test. + /// The escaped rendering. + /// A task representing the asynchronous operation. + [Test] + [Arguments("&x", "&x")] + [Arguments("x", ">x")] + [Arguments("\"x", ""x")] + [Arguments("'x", "'x")] + public async Task SymbolHelpersDetectsEverySpecialCharacterAsTheLeadingOne(string value, string expected) => + await Assert.That(SymbolHelpers.EscapeXml(value)).IsEqualTo(expected); + + /// Verifies a generic reference is escaped so the generated comment stays well-formed XML. + /// A task representing the asynchronous operation. + [Test] + public async Task SymbolHelpersEscapesGenericReferencesForDocumentation() => + await Assert.That(SymbolHelpers.EscapeXml("global::Samples.Outer.Inner")) + .IsEqualTo("global::Samples.Outer<T>.Inner<T2>"); +}