Skip to content

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/.csproj files into a Solution/Project model — 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

[*.cs]
dotnet_diagnostic.ACME001.severity = error

Per-rule severity, scoped by glob. Override per file/folder/repo.

Concurrent analysis

context.EnableConcurrentExecution();

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

var symbol = ctx.SemanticModel.Compilation.GetSymbolsWithName("Foo").ToList();   // expensive

Slows IDE typing latency; budget your analyzer.

✅ Correct: scope precisely

context.RegisterSymbolAction(ctx => /* ... */, SymbolKind.Method);

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 Workspace in a long-running process without disposing — leaks compilations.
  • Targeting net6.0 instead of netstandard2.0 for 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.

Further reading