Skip to content

Source Generators

Termina uses C# source generators to eliminate boilerplate code while maintaining AOT compatibility. This page explains what gets generated and how it works.

Why Source Generators?

Source generators provide:

  1. No Reflection - Code is generated at compile time, not runtime
  2. AOT Compatible - Works with Native AOT publishing
  3. IDE Support - Generated code appears in IntelliSense
  4. Debuggable - You can step into generated code

The [Reactive] Generator

What You Write

csharp
public partial class CounterViewModel : ReactiveViewModel
{
    [Reactive] private int _count;
    [Reactive] private string _status = "Ready";
}

What Gets Generated

csharp
// CounterViewModel.g.cs (generated)
partial class CounterViewModel
{
    private readonly BehaviorSubject<int> _countSubject = new(default);

    public int Count
    {
        get => _countSubject.Value;
        set => _countSubject.OnNext(value);
    }

    public IObservable<int> CountChanged => _countSubject.AsObservable();

    private readonly BehaviorSubject<string> _statusSubject = new("Ready");

    public string Status
    {
        get => _statusSubject.Value;
        set => _statusSubject.OnNext(value);
    }

    public IObservable<string> StatusChanged => _statusSubject.AsObservable();
}

The [FromRoute] Generator

What You Write

csharp
public partial class DetailViewModel : ReactiveViewModel
{
    [FromRoute] private int _id;
}

What Gets Generated

csharp
// DetailViewModel.g.cs (generated)
partial class DetailViewModel
{
    public int Id { get; private set; }

    internal void ApplyRouteParameters(IDictionary<string, object> parameters)
    {
        if (parameters.TryGetValue("id", out var idValue))
        {
            Id = Convert.ToInt32(idValue);
        }
    }
}

Viewing Generated Code

To see the generated code in your IDE, add to your project file:

xml
<PropertyGroup>
  <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
  <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>

Generated files appear in obj/Generated/.

Generator Implementation

The generators are in the Termina.Generators project:

View ReactivePropertyGenerator
csharp
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace Termina.Generators;

/// <summary>
/// Source generator that generates reactive properties for fields marked with [Reactive]
/// and route parameter receivers for fields marked with [FromRoute].
///
/// For each [Reactive] field, generates:
/// - A BehaviorSubject backing field
/// - A public property with get/set
/// - A public IObservable property for subscriptions
///
/// For each [FromRoute] field, generates:
/// - A public read-only property
/// - IRouteParameterReceiver implementation with SetRouteParameters method
/// </summary>
[Generator]
public class ReactivePropertyGenerator : IIncrementalGenerator
{
    private const string ReactiveAttributeName = "Reactive";
    private const string ReactiveAttributeFullName = "Termina.Reactive.ReactiveAttribute";
    private const string FromRouteAttributeName = "FromRoute";
    private const string FromRouteAttributeFullName = "Termina.Routing.FromRouteAttribute";

    /// <summary>
    /// Symbol display format that includes nullable reference type annotations (e.g., string? instead of string).
    /// Based on FullyQualifiedFormat but with IncludeNullableReferenceTypeModifier added.
    /// </summary>
    private static readonly SymbolDisplayFormat FullyQualifiedFormatWithNullability = new(
        globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included,
        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
        genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
        miscellaneousOptions: SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers |
                              SymbolDisplayMiscellaneousOptions.UseSpecialTypes |
                              SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier);

    /// <summary>
    /// Diagnostic reported when a ReactiveViewModel subclass has a custom Dispose() method
    /// but needs to call DisposeReactiveFields() to properly dispose generated BehaviorSubjects.
    /// </summary>
    private static readonly DiagnosticDescriptor MustCallDisposeReactiveFields = new(
        id: "TERMINA001",
        title: "Must call DisposeReactiveFields() in custom Dispose() override",
        messageFormat: "Class '{0}' inherits from ReactiveViewModel and has [Reactive] fields, but defines a custom Dispose() method. You must call DisposeReactiveFields() in your Dispose() implementation to properly dispose generated BehaviorSubject backing fields.",
        category: "Termina.Reactive",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true,
        description: "When a ReactiveViewModel subclass has [Reactive] fields and defines its own Dispose() method, it must call the generated DisposeReactiveFields() method to ensure BehaviorSubject backing fields are properly disposed.");

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Find all field declarations with [Reactive] or [FromRoute] attribute
        var fieldsWithAttribute = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (node, _) => IsFieldWithReactiveOrFromRouteAttribute(node),
                transform: static (ctx, _) => GetFieldInfo(ctx))
            .Where(static f => f is not null);

        // Collect and generate
        context.RegisterSourceOutput(
            fieldsWithAttribute.Collect(),
            static (spc, fields) => GenerateSource(spc, fields!));
    }

    private static bool IsFieldWithReactiveOrFromRouteAttribute(SyntaxNode node)
    {
        // Look for field declarations
        if (node is not FieldDeclarationSyntax fieldDeclaration)
            return false;

        // Check if any attribute list contains [Reactive] or [FromRoute]
        foreach (var attributeList in fieldDeclaration.AttributeLists)
        {
            foreach (var attribute in attributeList.Attributes)
            {
                var name = attribute.Name.ToString();
                if (name == ReactiveAttributeName || name == "ReactiveAttribute" ||
                    name == "Termina.Reactive.Reactive" || name == ReactiveAttributeFullName)
                {
                    return true;
                }
                if (name == FromRouteAttributeName || name == "FromRouteAttribute" ||
                    name == "Termina.Routing.FromRoute" || name == FromRouteAttributeFullName)
                {
                    return true;
                }
            }
        }

        return false;
    }

    private static FieldInfo? GetFieldInfo(GeneratorSyntaxContext context)
    {
        var fieldDeclaration = (FieldDeclarationSyntax)context.Node;

        // Get the containing type
        if (fieldDeclaration.Parent is not TypeDeclarationSyntax typeDeclaration)
            return null;

        // Check that the containing type is partial
        var isPartial = typeDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
        if (!isPartial)
        {
            // Skip - class must be partial for generated code to work
            return null;
        }

        // Get the semantic model
        var semanticModel = context.SemanticModel;

        // Get the field symbol for the first variable (we handle one field per declaration)
        var variableDeclarator = fieldDeclaration.Declaration.Variables.FirstOrDefault();
        if (variableDeclarator == null)
            return null;

        var fieldSymbol = semanticModel.GetDeclaredSymbol(variableDeclarator) as IFieldSymbol;
        if (fieldSymbol == null)
            return null;

        // Check which attributes the field has
        var attributes = fieldSymbol.GetAttributes();
        var hasReactiveAttribute = attributes.Any(a => a.AttributeClass?.ToDisplayString() == ReactiveAttributeFullName);
        var fromRouteAttribute = attributes.FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == FromRouteAttributeFullName);
        var hasFromRouteAttribute = fromRouteAttribute != null;

        // Must have at least one of the attributes
        if (!hasReactiveAttribute && !hasFromRouteAttribute)
            return null;

        // Get the explicit route parameter name if specified
        string? routeParameterName = null;
        if (fromRouteAttribute != null)
        {
            var nameArg = fromRouteAttribute.NamedArguments.FirstOrDefault(a => a.Key == "Name");
            if (!nameArg.Value.IsNull)
            {
                routeParameterName = nameArg.Value.Value as string;
            }
        }

        // Get field name and type (including nullability annotations)
        var fieldName = fieldSymbol.Name;
        var fieldType = fieldSymbol.Type.ToDisplayString(FullyQualifiedFormatWithNullability);

        // Get the initial value if present
        string? initialValue = null;
        if (variableDeclarator.Initializer is not null)
        {
            initialValue = variableDeclarator.Initializer.Value.ToString();
        }

        // Get containing type info
        var containingType = fieldSymbol.ContainingType;
        var namespaceName = containingType.ContainingNamespace.IsGlobalNamespace
            ? null
            : containingType.ContainingNamespace.ToDisplayString();
        var typeName = containingType.Name;

        // Check if the containing type inherits from ReactiveViewModel
        var inheritsFromReactiveViewModel = InheritsFromType(containingType, "Termina.Reactive.ReactiveViewModel");

        // Check if the class already has a Dispose() method defined
        var disposeMethod = containingType.GetMembers("Dispose")
            .OfType<IMethodSymbol>()
            .FirstOrDefault(m => m.Parameters.Length == 0 &&
                      m.ReturnType.SpecialType == SpecialType.System_Void &&
                      !m.IsImplicitlyDeclared);
        var hasExistingDisposeMethod = disposeMethod is not null;

        // Check if the existing Dispose method calls DisposeReactiveFields()
        var disposeCallsDisposeReactiveFields = false;
        if (disposeMethod is not null)
        {
            // Get the syntax reference for the dispose method
            var syntaxRef = disposeMethod.DeclaringSyntaxReferences.FirstOrDefault();
            if (syntaxRef is not null)
            {
                var disposeSyntax = syntaxRef.GetSyntax();
                // Check if the method body contains a call to DisposeReactiveFields
                disposeCallsDisposeReactiveFields = disposeSyntax.DescendantNodes()
                    .OfType<InvocationExpressionSyntax>()
                    .Any(inv =>
                    {
                        // Check for direct call: DisposeReactiveFields()
                        if (inv.Expression is IdentifierNameSyntax id &&
                            id.Identifier.Text == "DisposeReactiveFields")
                            return true;

                        // Check for this.DisposeReactiveFields()
                        if (inv.Expression is MemberAccessExpressionSyntax ma &&
                            ma.Name.Identifier.Text == "DisposeReactiveFields")
                            return true;

                        return false;
                    });
            }
        }

        // Get the location of the type declaration for diagnostics
        var typeLocation = typeDeclaration.Identifier.GetLocation();

        return new FieldInfo(
            namespaceName,
            typeName,
            fieldName,
            fieldType,
            initialValue,
            containingType.ToDisplayString(),
            hasReactiveAttribute,
            hasFromRouteAttribute,
            routeParameterName,
            inheritsFromReactiveViewModel,
            hasExistingDisposeMethod,
            disposeCallsDisposeReactiveFields,
            typeLocation);
    }

    private static bool InheritsFromType(INamedTypeSymbol? type, string baseTypeName)
    {
        var current = type?.BaseType;
        while (current != null)
        {
            // Check against multiple possible display formats
            var displayString = current.ToDisplayString();
            if (displayString == baseTypeName)
                return true;

            // Also check the fully qualified name (namespace + name)
            var fullyQualifiedName = current.ContainingNamespace.IsGlobalNamespace
                ? current.Name
                : $"{current.ContainingNamespace.ToDisplayString()}.{current.Name}";
            if (fullyQualifiedName == baseTypeName)
                return true;

            // Also check just by type name and namespace separately (for metadata types)
            if (current.Name == "ReactiveViewModel" &&
                current.ContainingNamespace.ToDisplayString() == "Termina.Reactive")
                return true;

            current = current.BaseType;
        }
        return false;
    }

    private static void GenerateSource(SourceProductionContext context, ImmutableArray<FieldInfo?> fields)
    {
        // Group fields by containing type
        var fieldsByType = fields
            .Where(f => f is not null)
            .Cast<FieldInfo>()
            .GroupBy(f => f.FullTypeName);

        foreach (var group in fieldsByType)
        {
            var fieldList = group.ToList();
            var firstField = fieldList.First();
            var reactiveFields = fieldList.Where(f => f.IsReactive).ToList();

            // Report diagnostic if user has custom Dispose() but doesn't call DisposeReactiveFields()
            if (reactiveFields.Count > 0 &&
                firstField.InheritsFromReactiveViewModel &&
                firstField.HasExistingDisposeMethod &&
                !firstField.DisposeCallsDisposeReactiveFields &&
                firstField.TypeLocation is not null)
            {
                var diagnostic = Diagnostic.Create(
                    MustCallDisposeReactiveFields,
                    firstField.TypeLocation,
                    firstField.TypeName);
                context.ReportDiagnostic(diagnostic);
            }

            var source = GeneratePartialClass(firstField.Namespace, firstField.TypeName, fieldList);
            context.AddSource($"{firstField.TypeName}.Reactive.g.cs", SourceText.From(source, Encoding.UTF8));
        }
    }

    private static string GeneratePartialClass(string? namespaceName, string typeName, List<FieldInfo> fields)
    {
        var sb = new StringBuilder();
        var reactiveFields = fields.Where(f => f.IsReactive).ToList();
        var fromRouteFields = fields.Where(f => f.IsFromRoute).ToList();
        var hasFromRouteFields = fromRouteFields.Count > 0;

        sb.AppendLine("// <auto-generated />");
        sb.AppendLine("#nullable enable");
        sb.AppendLine();
        sb.AppendLine("using System;");
        if (reactiveFields.Count > 0)
        {
            sb.AppendLine("using System.Reactive.Linq;");
            sb.AppendLine("using System.Reactive.Subjects;");
        }
        if (hasFromRouteFields)
        {
            sb.AppendLine("using System.Collections.Generic;");
            sb.AppendLine("using Termina.Routing;");
        }
        sb.AppendLine();

        if (namespaceName is not null)
        {
            sb.AppendLine($"namespace {namespaceName};");
            sb.AppendLine();
        }

        // Add IRouteParameterReceiver interface if needed
        if (hasFromRouteFields)
        {
            sb.AppendLine($"partial class {typeName} : IRouteParameterReceiver");
        }
        else
        {
            sb.AppendLine($"partial class {typeName}");
        }
        sb.AppendLine("{");

        // Generate reactive properties
        foreach (var field in reactiveFields)
        {
            GenerateReactiveProperty(sb, field);
        }

        // Generate route parameter properties (read-only)
        foreach (var field in fromRouteFields)
        {
            GenerateRouteProperty(sb, field);
        }

        // Generate IRouteParameterReceiver implementation if needed
        if (hasFromRouteFields)
        {
            GenerateSetRouteParameters(sb, fromRouteFields);
        }

        // Generate disposal method for reactive fields
        if (reactiveFields.Count > 0)
        {
            GenerateDisposeReactiveFields(sb, reactiveFields);

            // Generate Dispose() override for ReactiveViewModel subclasses
            // (only if user hasn't already defined one)
            var firstField = reactiveFields.First();
            if (firstField.InheritsFromReactiveViewModel && !firstField.HasExistingDisposeMethod)
            {
                GenerateDisposeOverride(sb);
            }
        }

        sb.AppendLine("}");

        return sb.ToString();
    }

    private static void GenerateReactiveProperty(StringBuilder sb, FieldInfo field)
    {
        // Convert field name to property name: _fieldName -> FieldName
        var propertyName = GetPropertyName(field.FieldName);
        var subjectFieldName = $"{field.FieldName}Subject";

        // Default value for the BehaviorSubject
        var defaultValue = field.InitialValue ?? GetDefaultValue(field.FieldType);

        sb.AppendLine();
        sb.AppendLine($"    private readonly BehaviorSubject<{field.FieldType}> {subjectFieldName} = new({defaultValue});");
        sb.AppendLine();
        sb.AppendLine($"    public {field.FieldType} {propertyName}");
        sb.AppendLine("    {");
        sb.AppendLine($"        get => {subjectFieldName}.Value;");
        sb.AppendLine($"        set => {subjectFieldName}.OnNext(value);");
        sb.AppendLine("    }");
        sb.AppendLine();
        sb.AppendLine($"    public IObservable<{field.FieldType}> {propertyName}Changed => {subjectFieldName}.AsObservable();");
    }

    private static void GenerateRouteProperty(StringBuilder sb, FieldInfo field)
    {
        // Convert field name to property name: _fieldName -> FieldName
        var propertyName = GetPropertyName(field.FieldName);

        sb.AppendLine();
        sb.AppendLine($"    public {field.FieldType} {propertyName} => {field.FieldName};");
    }

    private static void GenerateSetRouteParameters(StringBuilder sb, List<FieldInfo> fromRouteFields)
    {
        sb.AppendLine();
        sb.AppendLine("    void IRouteParameterReceiver.SetRouteParameters(IReadOnlyDictionary<string, object> parameters)");
        sb.AppendLine("    {");

        foreach (var field in fromRouteFields)
        {
            var propertyName = GetPropertyName(field.FieldName);
            // Use explicit name from attribute if provided, otherwise derive from field name
            var parameterName = field.RouteParameterName ?? GetRouteParameterName(field.FieldName);

            sb.AppendLine($"        if (parameters.TryGetValue(\"{parameterName}\", out var {field.FieldName}Value))");
            sb.AppendLine($"            {field.FieldName} = ({field.FieldType}){field.FieldName}Value;");
        }

        sb.AppendLine("    }");
    }

    private static void GenerateDisposeReactiveFields(StringBuilder sb, List<FieldInfo> reactiveFields)
    {
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Disposes all BehaviorSubject backing fields generated by [Reactive] attributes.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    protected void DisposeReactiveFields()");
        sb.AppendLine("    {");

        foreach (var field in reactiveFields)
        {
            var subjectFieldName = $"{field.FieldName}Subject";
            sb.AppendLine($"        {subjectFieldName}.Dispose();");
        }

        sb.AppendLine("    }");
    }

    private static void GenerateDisposeOverride(StringBuilder sb)
    {
        sb.AppendLine();
        sb.AppendLine("    /// <summary>");
        sb.AppendLine("    /// Disposes reactive property backing fields and calls base disposal.");
        sb.AppendLine("    /// </summary>");
        sb.AppendLine("    public override void Dispose()");
        sb.AppendLine("    {");
        sb.AppendLine("        DisposeReactiveFields();");
        sb.AppendLine("        base.Dispose();");
        sb.AppendLine("    }");
    }

    private static string GetRouteParameterName(string fieldName)
    {
        // Remove leading underscore and use camelCase for route parameter
        // _taskId -> taskId
        if (fieldName.StartsWith("_") && fieldName.Length > 1)
        {
            return char.ToLowerInvariant(fieldName[1]) + fieldName.Substring(2);
        }

        // Just use as-is with lowercase first letter
        return char.ToLowerInvariant(fieldName[0]) + fieldName.Substring(1);
    }

    private static string GetPropertyName(string fieldName)
    {
        // Remove leading underscore and capitalize first letter
        if (fieldName.StartsWith("_") && fieldName.Length > 1)
        {
            return char.ToUpperInvariant(fieldName[1]) + fieldName.Substring(2);
        }

        // Just capitalize first letter
        return char.ToUpperInvariant(fieldName[0]) + fieldName.Substring(1);
    }

    private static string GetDefaultValue(string typeName)
    {
        // Handle common types
        return typeName switch
        {
            "int" or "global::System.Int32" => "0",
            "long" or "global::System.Int64" => "0L",
            "float" or "global::System.Single" => "0f",
            "double" or "global::System.Double" => "0d",
            "decimal" or "global::System.Decimal" => "0m",
            "bool" or "global::System.Boolean" => "false",
            "string" or "global::System.String" => "string.Empty",
            _ => "default!"
        };
    }

    private sealed class FieldInfo
    {
        public string? Namespace { get; }
        public string TypeName { get; }
        public string FieldName { get; }
        public string FieldType { get; }
        public string? InitialValue { get; }
        public string FullTypeName { get; }
        public bool IsReactive { get; }
        public bool IsFromRoute { get; }
        public string? RouteParameterName { get; }
        public bool InheritsFromReactiveViewModel { get; }
        public bool HasExistingDisposeMethod { get; }
        public bool DisposeCallsDisposeReactiveFields { get; }
        public Location? TypeLocation { get; }

        public FieldInfo(
            string? @namespace,
            string typeName,
            string fieldName,
            string fieldType,
            string? initialValue,
            string fullTypeName,
            bool isReactive,
            bool isFromRoute,
            string? routeParameterName,
            bool inheritsFromReactiveViewModel,
            bool hasExistingDisposeMethod,
            bool disposeCallsDisposeReactiveFields,
            Location? typeLocation)
        {
            Namespace = @namespace;
            TypeName = typeName;
            FieldName = fieldName;
            FieldType = fieldType;
            InitialValue = initialValue;
            FullTypeName = fullTypeName;
            IsReactive = isReactive;
            IsFromRoute = isFromRoute;
            RouteParameterName = routeParameterName;
            InheritsFromReactiveViewModel = inheritsFromReactiveViewModel;
            HasExistingDisposeMethod = hasExistingDisposeMethod;
            DisposeCallsDisposeReactiveFields = disposeCallsDisposeReactiveFields;
            TypeLocation = typeLocation;
        }
    }
}

Key Requirements

Partial Classes

Your ViewModel class must be partial:

csharp
// ✓ Correct
public partial class MyViewModel : ReactiveViewModel { }

// ✗ Won't work - missing partial
public class MyViewModel : ReactiveViewModel { }

Field Naming

Fields must start with _ and use camelCase:

csharp
// ✓ Correct
[Reactive] private int _count;        // → Count, CountChanged
[Reactive] private string _userName;  // → UserName, UserNameChanged

// ✗ Won't work correctly
[Reactive] private int count;         // No underscore
[Reactive] private int Count;         // PascalCase

Default Values

Default values are preserved:

csharp
[Reactive] private int _count = 10;              // BehaviorSubject initialized with 10
[Reactive] private List<string> _items = new();  // Initialized with empty list

Compile-Time Errors

The generator produces compile errors for common mistakes:

  • Non-partial class with [Reactive] attributes
  • Invalid field naming conventions
  • Missing required dependencies

Benefits for AOT

Traditional reactive libraries use reflection to:

  • Find properties at runtime
  • Create bindings dynamically
  • Invoke property changed notifications

Termina's source generator approach:

  • Generates all code at compile time
  • No runtime reflection
  • Full support for PublishAot=true
bash
# Works because no reflection is needed
dotnet publish -c Release -r win-x64 --self-contained /p:PublishAot=true

Released under the Apache 2.0 License.