Module 1 · Incremental source generators · Lesson 2 of 3
Why an incremental generator must be deterministic
IIncrementalGenerator.Initialize builds a pipeline. The host compares tracked values across runs and can reuse results. Keep a boundary between the expensive discovery work and the small model that determines the output.
That comparison is the whole point of "incremental". It only works if a step is a pure function of its input. The same syntax node and the same compilation must produce the same model, and that model must produce the same source text.
These make the pipeline unreliable or unnecessarily expensive:
- DateTime.Now, Guid.NewGuid, or randomness inside a transform. Such values are not declared inputs. A cached transform can retain its old value; if rerun, it can return something different. Neither behavior is dependable invalidation.
- Reading an arbitrary file, an environment variable, or the clock inside a transform. The compiler is not tracking those reads. For generator files, use AdditionalTextsProvider; for declared analyzer configuration, use AnalyzerConfigOptionsProvider. Project properties must be exposed to the compiler when using that configuration route.
- Depending on incidental discovery or collection order when assembling one output. Sort by a stable, sufficiently qualified key with an explicit comparison policy. Distinct types in A and B can both be named Item; a simple name alone is not a unique key.
The pipeline itself is three moves. A syntax provider selects candidate nodes, for example types with a marker attribute. A transform turns each node into a small equatable model: the type name and the data you will emit. RegisterSourceOutput writes one file from that model.
Combine and Collect join steps. Collect gives you an ImmutableArray; Combine introduces a dependency on both inputs. Choose equality deliberately. Newly allocated reference-equal-only models compare unequal, but reusing the same instance does not. A record containing an array or list does not automatically gain element-by-element collection equality. Extract just the values that affect output; avoid carrying symbols or compilation objects through the output model.
When the user's code violates the generator's supported contract, call ReportDiagnostic with a useful source location. Unexpected exceptions are generator failures, not good validation messages. Cancellation is different: honor the callback's cancellation token rather than swallowing it.
Incremental does not mean "runs once". Determinism asks whether the same declared inputs reproduce the same result; incrementality asks which work can be reused after a change. Test both rather than inferring one from the other.
Limit of the kitchen analogy: the written ingredient list represents tracked values and their equality checks. Roslyn may repeat discovery and still reuse later output when its model compares equal; the analogy does not promise that every earlier operation is skipped. In the example below, a fresh Candidate can become the same ("@Demo", "Parcel") tuple. Renaming the target to Shipment changes that tuple.
Watch
Build the generator from the first lesson
This example deliberately generates only a type-name method so the pipeline is visible. It is not a recommendation to replace a simple hand-written name with a generator. Its supported targets are top-level, non-generic, non-static, non-file-local partial classes. Records, nested types, repeated markers, a class itself named GeneratedName, and any existing GeneratedName member in the inheritance chain are rejected. Tin.GenerateNameAttribute is reserved for this example.
Create sibling folders Tin.Generator and Tin.App. Use a stable .NET 10 SDK; the code uses C# 12. The generator targets netstandard2.0 and pins Microsoft.CodeAnalysis.CSharp 4.14.0 as a reproducible API baseline. This is not the latest-package claim or a promise that every older IDE can load it. Keep the compiler host compatible with the Roslyn APIs you use.
Tin.Generator/Tin.Generator.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" PrivateAssets="all" />
</ItemGroup>
</Project>Tin.Generator/NameGenerator.cs, complete file:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace Tin.Generators;
[Generator(LanguageNames.CSharp)]
public sealed class NameGenerator : IIncrementalGenerator
{
private const string AttributeName = "Tin.GenerateNameAttribute";
private static readonly DiagnosticDescriptor InvalidTarget = new(
"TINSG001", "Unsupported generation target",
"Type '{0}' must be a top-level, non-generic, non-static, non-file-local partial class, " +
"whose name is not GeneratedName, with exactly one GenerateName attribute " +
"and no GeneratedName member in its hierarchy",
"Tin.SourceGeneration", DiagnosticSeverity.Error, isEnabledByDefault: true);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static output => output.AddSource(
"GenerateNameAttribute.g.cs", SourceText.From("""
namespace Tin
{
[global::System.AttributeUsage(global::System.AttributeTargets.Class,
AllowMultiple = false, Inherited = false)]
internal sealed class GenerateNameAttribute : global::System.Attribute { }
}
""", Encoding.UTF8)));
var candidates = context.SyntaxProvider.ForAttributeWithMetadataName(
AttributeName,
static (node, _) => node is TypeDeclarationSyntax,
static (target, token) => Analyze(target, token));
// Keep locations in the diagnostic branch, not the value-equal source model.
context.RegisterSourceOutput(candidates.Where(static item => !item.IsValid),
static (output, item) => output.ReportDiagnostic(
Diagnostic.Create(InvalidTarget, item.Location, item.Name)));
var models = candidates.Where(static item => item.IsValid)
.Select(static (item, _) => (item.Namespace, item.Name))
.WithTrackingName("TargetModel");
context.RegisterSourceOutput(models, static (output, model) =>
{
string qualifiedName = (model.Namespace.Length == 0 ? "" :
model.Namespace.Replace("@", "") + ".") + model.Name;
string body = "partial class @" + model.Name + "\n{\n" +
" public static string GeneratedName() => " +
SymbolDisplay.FormatLiteral(qualifiedName, quote: true) + ";\n}\n";
string source = model.Namespace.Length == 0 ? body :
"namespace " + model.Namespace + "\n{\n" + body + "}\n";
output.AddSource(qualifiedName + ".GeneratedName.g.cs",
SourceText.From(source, Encoding.UTF8));
});
}
private static Candidate Analyze(GeneratorAttributeSyntaxContext target,
CancellationToken token)
{
token.ThrowIfCancellationRequested();
var syntax = (TypeDeclarationSyntax)target.TargetNode;
var symbol = (INamedTypeSymbol)target.TargetSymbol;
bool supported = syntax is ClassDeclarationSyntax &&
symbol.ContainingType is null && symbol.Arity == 0 && !symbol.IsStatic &&
symbol.Name != "GeneratedName" &&
symbol.DeclaringSyntaxReferences.All(reference =>
{
token.ThrowIfCancellationRequested();
return reference.GetSyntax(token) is ClassDeclarationSyntax part &&
part.Modifiers.Any(SyntaxKind.PartialKeyword) &&
!part.Modifiers.Any(SyntaxKind.FileKeyword);
}) &&
symbol.GetAttributes().Count(attribute =>
attribute.AttributeClass?.ToDisplayString() == AttributeName) == 1;
for (INamedTypeSymbol? type = symbol; type is not null; type = type.BaseType)
supported &= type.GetMembers("GeneratedName").Length == 0;
var namespaceParts = new Stack<string>();
for (var space = symbol.ContainingNamespace; !space.IsGlobalNamespace;
space = space.ContainingNamespace)
namespaceParts.Push("@" + space.Name);
return new Candidate(string.Join(".", namespaceParts), symbol.Name,
supported, syntax.Identifier.GetLocation());
}
private sealed class Candidate
{
public Candidate(string ns, string name, bool isValid, Location location)
{
Namespace = ns;
Name = name;
IsValid = isValid;
Location = location;
}
public string Namespace { get; }
public string Name { get; }
public bool IsValid { get; }
public Location Location { get; }
}
}Trace the pipeline
- Post-initialization contributes the marker declaration. The application does not ship or construct the generator to get that declaration.
- Analyze validates the supported shape and extracts names. The temporary Candidate also carries a location for a useful error.
- The diagnostic branch reports TINSG001 for an invalid candidate. The successful branch projects to a tuple of strings, dropping its location before source generation.
- That tuple is the output contract: namespace plus type name. Fresh Candidate objects do not by themselves force this final value model to change.
- The callback escapes the generated literal and identifiers, uses fixed newlines, and includes the namespace in its hint. A.Item and B.Item therefore do not compete for Item.g.cs.
Tin.App/Tin.App.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>12.0</LangVersion>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../Tin.Generator/Tin.Generator.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>Use the complete Tin.App/Program.cs from the previous lesson. From the folder containing both project folders, build and run:
dotnet build Tin.App/Tin.App.csproj dotnet run --project Tin.App/Tin.App.csproj --no-build
The expected application output is Demo.Parcel. Generated files can be inspected under Tin.App/obj for this configuration; do not add them back into the project as manual source. The third lesson checks the in-memory output independently of that folder.
Two changes to reason through
- Add an unrelated class. Discovery may do work again, but this example still extracts the same namespace/name tuple for Parcel. The generated method should be identical. This is the boundary worth measuring.
- Remove partial from Parcel. This is no longer a supported target. TINSG001 should identify its name, and the extra Parcel declaration should not be emitted. A remaining call to GeneratedName can also produce a normal compiler error; do not suppress it to preserve a false successful build.
Video companion
Watch C# Source Generators – Why and How, Jim Wooley on Microsoft Visual Studio. The creator's chapters at 43:10, 45:30, and 47:25 cover incremental generators, caching and value semantics. Use the written lesson above for the exact tracked-input and attribute-discovery contract of this example.
Analogy
An incremental generator is a kitchen that keeps a written ingredient list for each prepared dish. A change outside that list is invisible to its reuse decision. If the cook secretly adds a timestamp, reusing the dish can preserve an old timestamp; cooking it again can produce a new one for the same written order.
Quick reference
- Initialize registers the pipeline and any post-initialization output callback.
- Same input must produce the same model and the same source text.
- Do not read the clock, random values, or files the compiler is not tracking.
- Sort symbols before emitting so file contents do not depend on walk order.
- Use value-equal output models; check collection fields and dependency breadth.
- Report unsupported input with diagnostics and honor cancellation.