Why Deepstaging.Roslyn¶
You've decided to build a source generator. Maybe you want typed IDs, or auto-mappers, or validation from attributes. The concept is simple. Then you open the Roslyn APIs.
GetMembers().OfType<IPropertySymbol>().Where(p => p.DeclaredAccessibility == Accessibility.Public). Get an attribute: GetAttributes().FirstOrDefault(a => a.AttributeClass?.Name == "MyAttribute"). Extract a named argument: attr.NamedArguments.FirstOrDefault(x => x.Key == "Name").Value.Value?.ToString(). Generate a property: SyntaxFactory.PropertyDeclaration(SyntaxFactory.ParseTypeName("string"), "Name").WithModifiers(...). Test it: create a CSharpCompilation, add MetadataReference objects, run the generator, assert on the output text.
All of this is correct C#. None of it is pleasant. And none of it has anything to do with the feature you're trying to build.
Deepstaging.Roslyn replaces that ceremony with APIs that feel like the standard library Roslyn forgot to ship.
Finding Symbols Shouldn't Require Archaeology¶
Roslyn gives you INamedTypeSymbol with 60+ members and no guidance on how to filter them. Finding "all public partial classes with my attribute" requires chaining OfType, Where, accessibility checks, modifier checks, and attribute name comparisons — every time.
// ❌ Raw Roslyn — verbose, fragile, easy to get wrong
var types = compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type)
.OfType<INamedTypeSymbol>()
.Where(t => t.DeclaredAccessibility == Accessibility.Public)
.Where(t => t.DeclaringSyntaxReferences.Any(r =>
r.GetSyntax() is TypeDeclarationSyntax tds &&
tds.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))))
.Where(t => t.GetAttributes().Any(a =>
a.AttributeClass?.Name == "AutoNotifyAttribute"));
// ✅ Deepstaging.Roslyn — says what it means
var types = TypeQuery.From(compilation)
.ThatArePublic()
.ThatAreClasses()
.ThatArePartial()
.WithAttribute<AutoNotifyAttribute>()
.GetAll();
Same result. One is code you skim. The other is code you read.
Queries exist for every symbol type — TypeQuery, MethodQuery, PropertyQuery, FieldQuery, ConstructorQuery, EventQuery, ParameterQuery. They all compose the same way, and they all return real ISymbol objects — no wrappers that hide the platform.
Attribute Data Is a Null Minefield¶
Extracting data from AttributeData is where most Roslyn code turns ugly. Constructor arguments are positional. Named arguments are KeyValuePair lookups. Types come back as ITypeSymbol?. Everything is nullable. Every access needs a guard.
// ❌ Raw Roslyn — null checks at every turn
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "StrongIdAttribute");
if (attr == null) return;
var backingType = attr.NamedArguments
.FirstOrDefault(x => x.Key == "BackingType").Value.Value;
var btEnum = backingType is int bt ? (BackingType)bt : BackingType.Guid;
var converters = attr.NamedArguments
.FirstOrDefault(x => x.Key == "Converters").Value.Value;
var cvEnum = converters is int cv ? (IdConverters)cv : IdConverters.None;
// ✅ Deepstaging.Roslyn — safe access, no ceremony
var attr = symbol.GetAttribute<StrongIdAttribute>();
if (attr.IsNotValid(out var valid))
return;
var query = valid.AsQuery<StrongIdAttributeQuery>();
var backingType = query.BackingType; // defaults handled inside
var converters = query.Converters; // enum cast handled inside
OptionalSymbol<T>, ValidSymbol<T>, OptionalAttribute, and ValidAttribute wrap Roslyn's nullable data in a consistent pattern. The AttributeQuery base class gives you typed access to constructor args, named args, and type args — with OrDefault(), OrNull(), and ToEnum<T>() for safe extraction.
Generating Code Shouldn't Mean Fighting SyntaxFactory¶
SyntaxFactory is the official API for building syntax trees. It's also 4,000+ members of positional arguments, token lists, and trivia management. Building a simple class requires dozens of nested calls.
// ❌ SyntaxFactory — technically correct, practically unreadable
SyntaxFactory.CompilationUnit()
.AddMembers(SyntaxFactory.NamespaceDeclaration(
SyntaxFactory.ParseName("MyApp"))
.AddMembers(SyntaxFactory.ClassDeclaration("Customer")
.AddModifiers(
SyntaxFactory.Token(SyntaxKind.PublicKeyword),
SyntaxFactory.Token(SyntaxKind.PartialKeyword))
.AddMembers(SyntaxFactory.PropertyDeclaration(
SyntaxFactory.ParseTypeName("string"), "Name")
.AddModifiers(SyntaxFactory.Token(SyntaxKind.PublicKeyword))
.AddAccessorListAccessors(
SyntaxFactory.AccessorDeclaration(SyntaxKind.GetAccessorDeclaration)
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)),
SyntaxFactory.AccessorDeclaration(SyntaxKind.SetAccessorDeclaration)
.WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))))));
// ✅ Deepstaging.Roslyn — reads like what it produces
TypeBuilder.Class("Customer")
.InNamespace("MyApp")
.AsPartial()
.AddProperty("Name", "string", p => p.WithGetter().WithSetter())
.Emit();
TypeBuilder, MethodBuilder, PropertyBuilder, FieldBuilder, ConstructorBuilder — each composes with the others. Parse complex signatures from strings when that's clearer: MethodBuilder.Parse("public static Eff<RT, Option<T>> FindById<RT>(CustomerId id)"). Either way you get valid CompilationUnitSyntax.
Common patterns like IEquatable<T>, IComparable<T>, INotifyPropertyChanged, and IParsable<T> are built in as single method calls.
Analyzers Don't Need 50 Lines of Boilerplate¶
A Roslyn analyzer that checks "type must be partial" is conceptually one boolean. In practice it's a class, a DiagnosticDescriptor, SupportedDiagnostics, Initialize, RegisterSymbolAction, symbol kind filtering, attribute checking, and diagnostic creation.
// ❌ Raw Roslyn — 30+ lines for one boolean check
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MustBePartialAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
"RK001", "Must be partial",
"Type '{0}' with [AutoNotify] must be partial",
"Usage", DiagnosticSeverity.Error, true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
[Rule];
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(
GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSymbolAction(Analyze, SymbolKind.NamedType);
}
private static void Analyze(SymbolAnalysisContext ctx)
{
if (ctx.Symbol is not INamedTypeSymbol type) return;
if (!type.GetAttributes().Any(a =>
a.AttributeClass?.Name == "AutoNotifyAttribute")) return;
if (type.DeclaringSyntaxReferences.Any(r =>
r.GetSyntax() is TypeDeclarationSyntax t &&
t.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))) return;
ctx.ReportDiagnostic(Diagnostic.Create(Rule, type.Locations[0], type.Name));
}
}
// ✅ Deepstaging.Roslyn — the boolean is all you write
[Reports("RK001", "Type with [AutoNotify] must be partial",
Category = "Usage", Severity = DiagnosticSeverity.Error)]
public sealed class MustBePartialAnalyzer : TypeAnalyzer<AutoNotifyAttribute>
{
protected override bool ShouldReport(ValidSymbol<INamedTypeSymbol> type)
=> !type.IsPartial;
}
Base classes exist for every symbol kind — TypeAnalyzer<T>, MethodAnalyzer<T>, PropertyAnalyzer<T>, FieldAnalyzer<T>, EventAnalyzer<T>, ParameterAnalyzer<T>. For multi-diagnostic scenarios, MultiDiagnosticTypeAnalyzer<TItem> reports per-item diagnostics. For assembly-level attributes, AssemblyAttributeAnalyzer<TItem> handles batch analysis.
Code Fixes Should Be as Easy as Analyzers¶
An analyzer identifies a problem. A code fix repairs it. In raw Roslyn, the code fix requires its own class, provider registration, diagnostic matching, document manipulation, and syntax rewriting. In Deepstaging.Roslyn:
[CodeFix("RK001")]
[ExportCodeFixProvider(LanguageNames.CSharp)]
public sealed class MustBePartialCodeFix : StructCodeFix
{
protected override CodeAction CreateFix(
Document document, ValidSyntax<StructDeclarationSyntax> syntax)
=> document.AddPartialModifierAction(syntax);
}
Pre-built actions handle common fixes: AddPartialModifierAction, AddModifierAction, AddAttributeAction, RenameAction. For project-level fixes (adding NuGet packages, modifying .csproj or .props files), ProjectFileActionsBuilder and ManagedPropsFile handle the MSBuild XML manipulation.
Incremental Generators Are Hard to Get Right¶
Roslyn's incremental generator pipeline caches aggressively. If your model type doesn't implement Equals correctly, the generator either reruns on every keystroke (too slow) or never reruns after changes (stale output). Getting this right means implementing IEquatable<T> on every model, using ImmutableArray<T> instead of arrays, and handling nested record equality.
// ✅ One attribute — correct equality generated for you
[PipelineModel]
internal sealed record AutoNotifyModel(
string Namespace,
string TypeName,
EquatableArray<FieldModel> Fields);
[PipelineModel] generates correct Equals/GetHashCode implementations. EquatableArray<T> replaces ImmutableArray<T> with structural equality. TypeSnapshot, AttributeSnapshot, and other snapshot types capture Roslyn symbol data in a serialization-safe, equality-correct form.
Testing Generators Is a Solved Problem¶
Testing a source generator in raw Roslyn means creating a CSharpCompilation, adding MetadataReference objects for every assembly your test code needs, running the generator driver, and comparing output strings. It's tedious and fragile.
// ✅ Deepstaging.Roslyn.Testing — test a generator in three lines
[Test]
public Task generates_customer_dto() => Context
.ForSource("Customer.cs", """
[AutoNotify]
public partial class Customer
{
private string _name;
private int _age;
}
""")
.AssertOutput("MyApp.Customer.g.cs");
RoslynTestBase provides test contexts for generators, analyzers, code fixes, and Scriban templates. Generator tests use snapshot files — the expected output lives in a .verified.cs file next to the test. Analyzer tests assert diagnostics by ID, location, and message. Code fix tests verify the transformed source. All share consistent APIs and handle compilation setup automatically.
Project Organization Has a Proven Pattern¶
A Roslyn toolkit isn't one project. It's attributes (for users), projection (shared queries), generators, analyzers, code fixes, and tests — with strict dependency rules between them.
The dotnet new roslynkit template scaffolds all of this:
src/
├── MyLib/ Attributes — user-facing, no Roslyn dependency
├── MyLib.Projection/ Queries + Models — shared between gen and analyzers
├── MyLib.Generators/ Source generators — thin pipeline wiring
├── MyLib.Analyzers/ Analyzers — one class per rule
├── MyLib.CodeFixes/ Code fixes — paired with analyzers
└── MyLib.Runtime/ Optional base classes
test/
└── MyLib.Tests/ Generator, analyzer, code fix, and template tests
Single-package NuGet bundling puts everything in one package — attributes in lib/, generators and analyzers in analyzers/, runtime in lib/net10.0/. Your users run dotnet add package MyLib and get the full experience.
You're Not Locked In¶
Every Deepstaging.Roslyn API returns standard Roslyn types. TypeQuery.GetAll() returns INamedTypeSymbol[]. TypeBuilder.Emit() returns CompilationUnitSyntax. ValidSymbol<T> wraps T where T : ISymbol — unwrap it any time.
If you outgrow a helper, drop down to raw Roslyn for that one piece. The APIs compose — use fluent queries for discovery, raw Roslyn for a tricky transformation, and TypeBuilder for emission. Mix and match without penalty.