Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Emits the strongly typed <c>Events()</c> overloads that replace the placeholder at each call site.</summary>
internal static class ActivationExtensionsEmitter
{
/// <summary>The room to reserve for the file's fixed scaffolding.</summary>
private const int ScaffoldCapacity = 256;

/// <summary>The room to reserve per generated overload.</summary>
private const int OverloadCapacity = 256;

/// <summary>The indentation the generated overloads sit at, inside a namespace and a class.</summary>
private const int MethodIndent = SourceFileWriter.IndentWidth + SourceFileWriter.IndentWidth;

/// <summary>The indentation the generated overloads' bodies and constraints sit at.</summary>
private const int BodyIndent = MethodIndent + SourceFileWriter.IndentWidth;

/// <summary>Emits every generated activation overload into one partial-class file.</summary>
/// <param name="models">The overloads to emit, in request order.</param>
/// <returns>The generated source.</returns>
/// <remarks>
/// 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.
/// </remarks>
internal static string Emit(ImmutableArray<ActivationModel> 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();
}

/// <summary>Appends one activation overload.</summary>
/// <param name="builder">The destination builder.</param>
/// <param name="model">The overload to append.</param>
private static void AppendOverload(PooledStringBuilder builder, ActivationModel model) =>
_ = builder.AppendIndent(MethodIndent)
.Append("/// <summary>Gets observable wrappers for public events on <c>")
.Append(model.DocumentationName).AppendLine("</c>.</summary>")
.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();
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>The API a consumer writes against, injected before anything is scanned.</summary>
/// <remarks>
/// <para>
/// This is what makes the generator opt-in without a package-level runtime dependency: the placeholder
/// <c>Events&lt;T&gt;</c> 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.
/// </para>
/// <para>
/// Nothing here is annotated, and it deliberately carries no <c>#nullable</c> 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.
/// </para>
/// </remarks>
internal static class ActivationSource
{
/// <summary>The source injected during post-initialization.</summary>
internal const string Text = """
// <auto-generated />
namespace ReactiveUI.Primitives.ObservableEvents
{
/// <summary>Requests observable wrappers for public static events on a type.</summary>
[global::System.AttributeUsage(global::System.AttributeTargets.Assembly, AllowMultiple = true)]
internal sealed class GenerateStaticEventObservablesAttribute : global::System.Attribute
{
/// <summary>Initializes the request.</summary>
/// <param name="type">The static event host.</param>
public GenerateStaticEventObservablesAttribute(global::System.Type type) => Type = type;

/// <summary>Gets the requested static event host.</summary>
public global::System.Type Type { get; }
}

/// <summary>Contains activation extensions used by the observable-event generator.</summary>
internal static partial class ObservableGeneratorExtensions
{
/// <summary>Requests observable wrappers for the receiver's public events.</summary>
/// <typeparam name="T">The event host type.</typeparam>
/// <param name="eventHost">The event host.</param>
/// <returns>A placeholder replaced by a generated, strongly typed overload.</returns>
public static NullEvents Events<T>(this T eventHost) => default;
}

/// <summary>Placeholder returned until a strongly typed event wrapper is generated.</summary>
internal readonly struct NullEvents
{
}
}
""";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Builds the wrapper class and generated file names, from the identity of the host they belong to.</summary>
/// <remarks>
/// 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 - <c>Samples.A_B.C</c> and <c>Samples.A.B_C</c> both flatten to
/// <c>Samples_A_B_C</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.
/// </remarks>
internal static class GeneratedNames
{
/// <summary>The prefix of a generated wrapper class name.</summary>
private const string WrapperPrefix = "Rx";

/// <summary>The suffix of a generated wrapper class name.</summary>
private const string WrapperSuffix = "Events";

/// <summary>The prefix shared by every generated file name.</summary>
private const string HintPrefix = "ObservableEvents.";

/// <summary>The suffix of a generated instance wrapper file name.</summary>
private const string InstanceHintSuffix = ".Instance.g.cs";

/// <summary>The suffix of a generated static wrapper file name.</summary>
private const string StaticHintSuffix = ".Static.g.cs";

/// <summary>The offset basis of the FNV-1a hash that keeps sanitized names apart.</summary>
private const ulong HashOffsetBasis = 14_695_981_039_346_656_037;

/// <summary>The prime of the FNV-1a hash that keeps sanitized names apart.</summary>
private const ulong HashPrime = 1_099_511_628_211;

/// <summary>Builds the wrapper class name for a host.</summary>
/// <param name="identity">The host's fully qualified name.</param>
/// <returns>The wrapper class name.</returns>
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();
}

/// <summary>Builds the generated file name for an instance wrapper.</summary>
/// <param name="identity">The host's fully qualified name.</param>
/// <returns>The generated file name.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static string InstanceHintName(string identity) => HintName(identity, InstanceHintSuffix);

/// <summary>Builds the generated file name for one namespace's static wrappers.</summary>
/// <param name="namespaceName">The namespace, or an empty string for the global namespace.</param>
/// <returns>The generated file name.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static string StaticHintName(string namespaceName) => HintName(namespaceName, StaticHintSuffix);

/// <summary>Appends the readable, collision-resistant component of a generated name.</summary>
/// <param name="builder">The destination builder.</param>
/// <param name="identity">The identity to render.</param>
private static void AppendUniqueComponent(PooledStringBuilder builder, string identity)
{
AppendSanitized(builder, identity);
_ = builder.Append('_').Append(StableHash(identity));
}

/// <summary>Appends an identity with everything that cannot appear in an identifier folded to underscores.</summary>
/// <param name="builder">The destination builder.</param>
/// <param name="identity">The identity to sanitize.</param>
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 : '_');
}
}

/// <summary>Builds a generated file name from an identity and a category suffix.</summary>
/// <param name="identity">The identity the file is keyed on.</param>
/// <param name="suffix">The category suffix.</param>
/// <returns>The generated file name.</returns>
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();
}

/// <summary>Computes a deterministic FNV-1a hash of an identity.</summary>
/// <param name="identity">The identity to hash.</param>
/// <returns>The invariant uppercase hexadecimal hash.</returns>
private static string StableHash(string identity)
{
var hash = HashOffsetBasis;
foreach (var character in identity)
{
hash ^= character;
hash *= HashPrime;
}

return hash.ToString("X16", CultureInfo.InvariantCulture);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Emits the wrapper class that exposes one host's instance events as observables.</summary>
internal static class InstanceWrapperEmitter
{
/// <summary>The room to reserve for the file's fixed scaffolding.</summary>
private const int ScaffoldCapacity = 512;

/// <summary>The room to reserve per generated observable property.</summary>
private const int PropertyCapacity = 512;

/// <summary>Emits the wrapper for one host.</summary>
/// <param name="model">The host to wrap.</param>
/// <param name="provider">The observable implementation to write against.</param>
/// <returns>The generated source.</returns>
/// <remarks>
/// 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.
/// </remarks>
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();
}
}
Loading
Loading