Roslyn Compiler API & Workspaces
Key Points
- Roslyn is the open-source C#/VB compiler exposed as APIs:
SyntaxTree,SemanticModel,Compilation,Workspace. - Use cases: writing analyzers (warnings/errors at build time), code-fix providers (lightbulb suggestions), refactorings, custom dev tooling (linters, test discoverers).
- Source generators are a different Roslyn flavor — see the dedicated topic. Analyzers/codefixes are consumers of the compilation; SGs produce code.
- Workspaces API loads
.sln/.csprojfiles into aSolution/Projectmodel — used by IDEs (VS, Rider, OmniSharp) and CLI tools. - Senior choice: analyzers for codebase guardrails (e.g., "no
DateTime.Now"), incremental SGs for compile-time codegen, Roslynator/StyleCop for general lint.
Concepts (deep dive)
The four big types
SyntaxTree — parsed source as a tree of SyntaxNode/SyntaxToken
SemanticModel — symbol info: what does this identifier resolve to?
Compilation — set of trees + references + options; the unit of "build"
Workspace — host services (loads projects/solutions, hosts multiple compilations)
A minimal analyzer
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DateTimeNowAnalyzer : DiagnosticAnalyzer
{
private static readonly DiagnosticDescriptor Rule = new(
id: "ACME001",
title: "Avoid DateTime.Now",
messageFormat: "Use TimeProvider or DateTimeOffset.UtcNow instead",
category: "Reliability",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.RegisterSyntaxNodeAction(AnalyzeMember, SyntaxKind.SimpleMemberAccessExpression);
}
private static void AnalyzeMember(SyntaxNodeAnalysisContext ctx)
{
var ma = (MemberAccessExpressionSyntax)ctx.Node;
if (ma.Name.Identifier.Text != "Now") return;
var symbol = ctx.SemanticModel.GetSymbolInfo(ma).Symbol;
if (symbol is IPropertySymbol p && p.ContainingType.ToString() == "System.DateTime")
ctx.ReportDiagnostic(Diagnostic.Create(Rule, ma.GetLocation()));
}
}
Flags DateTime.Now everywhere it's used. Ship as a NuGet analyzer package; consumers see the warning at build time.
A code-fix provider
[ExportCodeFixProvider(LanguageNames.CSharp)]
public sealed class DateTimeNowCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds => ["ACME001"];
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var diagnostic = context.Diagnostics.First();
var span = diagnostic.Location.SourceSpan;
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken);
var node = root!.FindNode(span);
context.RegisterCodeFix(
CodeAction.Create("Replace with DateTimeOffset.UtcNow",
ct => ReplaceAsync(context.Document, node, ct), nameof(DateTimeNowCodeFix)),
diagnostic);
}
private static async Task<Document> ReplaceAsync(Document doc, SyntaxNode oldNode, CancellationToken ct)
{
var newNode = SyntaxFactory.ParseExpression("DateTimeOffset.UtcNow")
.WithTriviaFrom(oldNode);
var root = await doc.GetSyntaxRootAsync(ct);
return doc.WithSyntaxRoot(root!.ReplaceNode(oldNode, newNode));
}
}
Lightbulb in the IDE: "Replace with DateTimeOffset.UtcNow" — applies on click.
Distribution
<!-- Analyzer .csproj -->
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="..." PrivateAssets="all" />
</ItemGroup>
Ship via NuGet with IncludeBuildOutput=false and <TfmSpecificPackageFile Include="bin/$(Configuration)/netstandard2.0/$(AssemblyName).dll" PackagePath="analyzers/dotnet/cs" />.
Workspace and Solution API
using Microsoft.CodeAnalysis.MSBuild;
MSBuildLocator.RegisterDefaults();
var ws = MSBuildWorkspace.Create();
var solution = await ws.OpenSolutionAsync("MySolution.sln");
foreach (var project in solution.Projects)
{
var compilation = await project.GetCompilationAsync();
foreach (var tree in compilation!.SyntaxTrees)
// ...
}
Used by dotnet format, refactoring tools, code-search engines.
Severity & .editorconfig
Per-rule severity, scoped by glob. Override per file/folder/repo.
Concurrent analysis
Roslyn calls your analyzer in parallel for many files; analyzers must be thread-safe (no mutable static state).
Symbol vs syntax
Syntax: what's written ("a.b.c")
Symbol: what it resolves to (Field 'c' on Type 'B' on Namespace 'A')
Use SemanticModel.GetSymbolInfo(node) to bridge — but symbol queries cost compilation time, so prefer syntax-only analyzers when possible (faster IDE).
Speed: incremental analyzer pipelines
For source generators, IIncrementalGenerator (modern API) caches by input — only rerun pipeline stages whose inputs changed. For analyzers, register only the syntax kinds you care about; avoid RegisterCompilationAction unless needed.
Refactorings (one-off code transforms)
[ExportCodeRefactoringProvider(LanguageNames.CSharp)]
public class IntroduceParameterRefactoring : CodeRefactoringProvider
{
public override async Task ComputeRefactoringsAsync(CodeRefactoringContext context) { /* ... */ }
}
Refactorings appear in IDE's "Quick Actions" menu without an associated diagnostic.
Common scenarios
| Goal | Roslyn API |
|---|---|
"No DateTime.Now" rule | DiagnosticAnalyzer + CodeFix |
Auto-fix var → explicit type | DiagnosticAnalyzer + CodeFix |
| Generate DTO from interface | IIncrementalGenerator |
| Codebase-wide refactor (rename + propagate) | Workspaces + custom CLI |
| Lint commit messages from C# files | Roslyn parse + custom rule |
| Architecture rules ("Domain can't reference Infrastructure") | Symbol-level analyzer |
Code: correct vs wrong
❌ Wrong: analyzer with mutable state
private int _count; // shared across files; race condition
public override void Initialize(AnalysisContext c)
{
c.RegisterSyntaxNodeAction(ctx => _count++, SyntaxKind.IdentifierName);
}
✅ Correct: per-compilation state
public override void Initialize(AnalysisContext c)
{
c.EnableConcurrentExecution();
c.RegisterCompilationStartAction(start =>
{
var counts = new ConcurrentDictionary<string, int>();
start.RegisterSyntaxNodeAction(ctx =>
counts.AddOrUpdate(((IdentifierNameSyntax)ctx.Node).Identifier.Text, 1, (_, n) => n + 1),
SyntaxKind.IdentifierName);
start.RegisterCompilationEndAction(end =>
{ /* report aggregated diagnostics */ });
});
}
❌ Wrong: blocking call in analyzer
Slows IDE typing latency; budget your analyzer.
✅ Correct: scope precisely
Targets only the kind you need.
Design patterns for this topic
Pattern 1 — Domain-specific analyzer pack
Bundle analyzers for your codebase ("no DateTime.Now", "no public mutable static", "every [FromBody] must have [Required]"). Ship as an internal NuGet package referenced by every solution.
Pattern 2 — dotnet format + analyzers + fixall
Run dotnet format analyzers --fix in CI to auto-apply analyzer fixes; fail on remaining warnings.
Pattern 3 — Architecture analyzer
Use INamedTypeSymbol.ContainingNamespace to enforce dependency boundaries:
if (referencedNs.StartsWith("Acme.Infrastructure") &&
callerNs.StartsWith("Acme.Domain"))
ctx.ReportDiagnostic(/* domain shouldn't reference infrastructure */);
Pattern 4 — Workspaces-based codebase scan
CLI tool that loads a solution and scans for patterns (e.g., "find every public API missing XML docs"). Used by linting and CI gates.
Pros & cons / trade-offs
| Approach | Pros | Cons |
|---|---|---|
| Analyzer | Build-time enforcement | Slows IDE if poorly written |
| Code-fix | Auto-fix in IDE | Authoring is verbose |
| Workspace tool (CLI) | Cross-cutting analysis | Heavyweight startup |
| Source generator | Compile-time codegen | Different API (IIncrementalGenerator) |
| Roslynator / StyleCop | Off-the-shelf rules | Generic — may not fit your patterns |
When to use / when to avoid
- Reach for analyzers when you have a recurring code-review correction worth automating.
- Use code-fixes to make analyzer adoption painless.
- Use Workspaces for codebase-wide one-off transforms.
- Don't write an analyzer if Roslynator/StyleCop already covers it.
Interview Q&A
Q1. Difference between an analyzer and a source generator? A. Analyzer reads the compilation and reports diagnostics; can't change code. Source generator produces additional source files compiled into the same assembly. Both run as Roslyn components but use different APIs.
Q2. What's the SemanticModel? A. Per-syntax-tree object that resolves identifiers to symbols, types, and operations. Expensive to compute; cache when iterating.
Q3. Why must analyzers be thread-safe? A. Roslyn analyzes files in parallel by default. Mutable shared state corrupts results. Use RegisterCompilationStartAction for per-compilation aggregation.
Q4. How do you ship an analyzer? A. NuGet package with the DLL packed under analyzers/dotnet/cs/. Consumers get the analyzer wired into their build automatically — dotnet build picks it up via <PackageReference>.
Q5. What's IIncrementalGenerator? A. The modern source generator API (.NET 6+) — pipeline-based, with caching at each stage so only changed inputs trigger regeneration. Replaces the legacy ISourceGenerator.
Q6. How do .editorconfig rules interact with analyzers? A. EditorConfig sets per-rule severity (dotnet_diagnostic.ACME001.severity = error) and per-file scoping. Lets teams adopt analyzers gradually (warning → error).
Q7. What's a Workspace? A. The host abstraction that loads .sln / .csproj, manages multiple projects' compilations, and supports document operations. MSBuildWorkspace (CLI), VisualStudioWorkspace (VS), etc.
Q8. Difference between syntax actions and symbol actions? A. Syntax: triggered per syntax kind (SyntaxKind.MethodDeclaration); cheap. Symbol: triggered per compiled symbol (SymbolKind.Method); has full semantic info but slower. Pick the cheapest hook.
Gotchas / common mistakes
- Analyzer with
O(n²)complexity — IDE typing latency. - Reporting diagnostics on generated code — set
ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None). - Mutable static state in analyzer.
- Using
Workspacein a long-running process without disposing — leaks compilations. - Targeting
net6.0instead ofnetstandard2.0for the analyzer DLL — VS/Rider expect netstandard2.0. - Code-fix that produces invalid syntax — always parse-check before returning.
- Forgetting
WithTriviaFrom(oldNode)— replacements lose comments and whitespace.