📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How does the Roslyn compiler work, and how would you use the Roslyn APIs (syntax trees, semantic model, compilation) in your own tools?

Roslyn's phases, immutable syntax trees, Compilation and SemanticModel, IOperation, emit, the compiler server and using the APIs to build tools.

Senior Theory Advanced compiler roslyn semantic-model syntax-tree tooling

🎯 Key points

  • Roslyn parses, declares symbols, binds, analyses and emits IL, metadata and PDBs
  • Syntax trees are immutable and full-fidelity, including trivia
  • A Compilation combines trees, references and options; the SemanticModel answers meaning questions
  • IOperation gives a language-neutral view useful for analysers
  • The compiler server and reference assemblies speed up builds
  • The same APIs power IDEs, analysers, generators and code-mod tools

⏱ 30-second answer

Roslyn is the C# and VB compiler exposed as libraries. It parses source into immutable syntax trees (with full fidelity: every space and comment is kept as trivia), builds declarations and symbols, binds expressions to symbols and types, runs flow analysis, then lowers and emits IL, metadata and PDBs. The same objects are public: SyntaxTree, CSharpCompilation, SemanticModel, ISymbol and IOperation. IDEs, analysers, source generators and refactoring tools are all built on these APIs. You can use them to compile code at run time, write code-mod scripts that rewrite thousands of files, or build analysers that run in every build.

🗣 Full answer (senior)

How I would answer this in an interview

Roslyn is the .NET Compiler Platform: the C# and VB compilers rewritten as libraries with public APIs. Instead of a black box that turns source into DLLs, it exposes every stage — parsing, binding, emitting — so IDEs, analysers, source generators, refactoring tools and your own utilities can use the same understanding of code as the compiler. I'd describe the pipeline and the three core API concepts (syntax trees, semantic model, compilation), then show how I'd use them in a tool.

1. The compiler pipeline

Source text
  → Parser            → SyntaxTree (per file)
  → Declaration       → Symbols (types, members) for the whole compilation
  → Binder            → Bound trees, SemanticModel (types, overloads, conversions)
  → Flow analysis     → definite assignment, nullability, reachability
  → Lowering          → simpler constructs (foreach, async, lambdas...)
  → Emitter           → IL + metadata + PDB

Analysers run on syntax/semantic information; source generators run after initial parsing and add syntax trees before the final compilation.

2. Syntax trees

var tree = CSharpSyntaxTree.ParseText(File.ReadAllText("OrderService.cs"));
var root = tree.GetCompilationUnitRoot();
var methods = root.DescendantNodes().OfType<MethodDeclarationSyntax>();
  • Full fidelity: every character, including whitespace and comments (trivia), round-trips back to the exact text.
  • Immutable: modifications create new trees (root.ReplaceNode(...)), enabling safe concurrent use and easy refactorings.
  • Nodes (declarations, statements, expressions), tokens (keywords, identifiers, punctuation), trivia (whitespace, comments, directives).
  • Error-tolerant: incomplete code still parses (crucial for IDEs).
  • SyntaxFactory builds new nodes; SyntaxRewriter/CSharpSyntaxWalker traverse/transform.

Syntax alone doesn't know what Order refers to or which overload is called.

3. Compilation and semantic model

var references = Directory.GetFiles(Path.GetDirectoryName(typeof(object).Assembly.Location)!, "*.dll")
                          .Select(p => MetadataReference.CreateFromFile(p));
var compilation = CSharpCompilation.Create("Analysis", [tree], references,
    new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable));

var model = compilation.GetSemanticModel(tree);
foreach (var invocation in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
{
    if (model.GetSymbolInfo(invocation).Symbol is IMethodSymbol method &&
        method.ContainingType.ToDisplayString() == "System.DateTime" && method.Name == "get_Now")
        Console.WriteLine($"DateTime.Now at {invocation.GetLocation().GetLineSpan()}");
}
  • A Compilation = syntax trees + metadata references + options. It's the unit of semantic analysis, can produce diagnostics (GetDiagnostics()) and emit an assembly (Emit(stream)).
  • SemanticModel answers questions about a tree: GetSymbolInfo (what does this name/call bind to?), GetTypeInfo (type of an expression, converted type), GetDeclaredSymbol, GetConstantValue, data/control-flow analysis.
  • Symbols (INamedTypeSymbol, IMethodSymbol, ...) represent declarations from source or metadata, with rich info (accessibility, attributes, members, nullability).
  • IOperation API offers a language-neutral semantic tree of operations — often the easiest level for analysis.

4. Using it in your own tools

  • Workspaces (Microsoft.CodeAnalysis.Workspaces.MSBuild): MSBuildWorkspace.Create().OpenSolutionAsync("App.sln") loads real projects with references, so you can analyse or refactor a whole solution (requires MSBuildLocator.RegisterDefaults()).
  • Examples:
    • Custom code metrics/inventory (find all endpoints and their auth attributes).
    • Migration tools (rewrite Newtonsoft attributes to System.Text.Json, update APIs across hundreds of files).
    • Documentation generators reading XML docs and symbols.
    • Architecture checks (forbidden calls) beyond what analysers enforce.
    • Code generation scripts producing C# with SyntaxFactory (or raw strings).
    • Scripting (Microsoft.CodeAnalysis.CSharp.Scripting) for rules engines (with care).
  • For in-build enforcement, write analysers; for generated code, source generators; for one-off migrations, a console tool with Workspaces (or dotnet format/code fixes).

5. A production example

Migrating 400 controllers from Newtonsoft.Json attributes and JObject usage to System.Text.Json, we wrote a small Roslyn Workspaces tool: it loaded the solution, used the semantic model to find attribute usages ([JsonProperty("x")] → [JsonPropertyName("x")]) and JsonConvert calls, rewrote nodes with SyntaxFactory while preserving trivia, and reported cases needing manual work. It changed ~3,000 sites in minutes with a reviewable diff, far safer than regex replacement.

🔍 What the interviewer is checking

The interviewer checks you understand the compiler as a platform, not a black box.

Green flags: names phases and their APIs, immutability and full fidelity, syntax versus semantic model, IOperation, compiler server and reference assemblies, practical tool examples.

Red flags: only knows csc as a command; would use regex for large code changes; confuses syntax and semantics.

📚 Learn in depth

1. Red/green trees

Roslyn uses green nodes (immutable, parent-less, shared, cached) internally and red nodes (the public SyntaxNode API with parent pointers and positions) created lazily on top. This gives immutability and cheap incremental reparsing (only changed parts recreated).

2. Incremental parsing

tree.WithChangedText(newText) reuses unchanged green nodes — essential for IDE responsiveness.

3. Symbols across compilations

Symbols are tied to a compilation; compare with SymbolEqualityComparer.Default. For cross-compilation lookups (e.g. in Workspaces after edits), use SymbolFinder and symbol keys.

4. Workspaces model

Solution → Project → Document, all immutable snapshots. Edits create new solutions; workspace.TryApplyChanges(newSolution) writes to disk (MSBuildWorkspace). Formatter.FormatAsync and Simplifier tidy generated syntax.

5. Emitting

compilation.Emit(peStream, pdbStream) produces assemblies — used by scripting, testing tools (compile snippets in tests), and dynamic plugin compilation (rarely appropriate in production; security and load-context concerns).

6. Performance

Compilations are expensive to create; reuse them. Semantic models are cached per tree. Use IOperation for less allocation-heavy analysis in some cases. Avoid calling GetDiagnostics() repeatedly on large compilations.

Try it yourself

  1. Parse a file and list all method declarations with line numbers.
  2. Build a compilation and find all DateTime.Now usages via the semantic model.
  3. Use MSBuildWorkspace to load a solution and rename a method across projects with Renamer.
  4. Compile a small snippet in memory and load it in a test.

💻 Code

// dotnet add package Microsoft.CodeAnalysis.CSharp
using System.Reflection;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Emit;

SyntaxTree tree = CSharpSyntaxTree.ParseText("""
    public static class Calc
    {
        public static int Twice(int x) => x * 2;
    }
    """);

// Reference the same framework assemblies this app runs on
IEnumerable<MetadataReference> references =
    ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
        .Split(Path.PathSeparator)
        .Select(path => MetadataReference.CreateFromFile(path));

CSharpCompilation compilation = CSharpCompilation.Create(
    "Dynamic", [tree], references,
    new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release));

// Semantic question: what symbol is the method?
SemanticModel model = compilation.GetSemanticModel(tree);
MethodDeclarationSyntax method = tree.GetRoot().DescendantNodes().OfType<MethodDeclarationSyntax>().Single();
IMethodSymbol symbol = model.GetDeclaredSymbol(method)!;
Console.WriteLine($"{symbol.ContainingType.Name}.{symbol.Name} returns {symbol.ReturnType}");

// Emit and run
using var pe = new MemoryStream();
EmitResult result = compilation.Emit(pe);
if (!result.Success)
{
    foreach (Diagnostic d in result.Diagnostics) Console.WriteLine(d);
    return;
}

Assembly assembly = Assembly.Load(pe.ToArray());
object? value = assembly.GetType("Calc")!.GetMethod("Twice")!.Invoke(null, [21]);
Console.WriteLine(value); // 42

⚠ Common mistakes

1. Regex-based code rewrites

What happens: large refactors with find/replace or regex. Why it is wrong: misses semantics, breaks code, loses formatting. Do instead: Roslyn syntax/semantic rewrites.

2. Using syntax only for semantic questions

What happens: matching method names textually. Why it is wrong: can't distinguish overloads, aliases, extension methods. Do instead: use the semantic model or IOperation.

3. Missing references in compilations

What happens: creating compilations without framework references. Why it is wrong: symbols unresolved; wrong results. Do instead: add proper references or use Workspaces.

4. Comparing symbols with ==

What happens: reference comparisons of symbols. Why it is wrong: unreliable across contexts. Do instead: SymbolEqualityComparer.Default.

5. Ignoring trivia when rewriting

What happens: generated nodes lose comments/formatting. Why it is wrong: noisy diffs and lost comments. Do instead: preserve trivia (WithTriviaFrom) and format.

6. Forgetting MSBuildLocator

What happens: Workspaces fail to load projects. Why it is wrong: MSBuild assemblies not found. Do instead: register MSBuild before using workspaces.

7. Compiling user code in production apps

What happens: using Roslyn to compile arbitrary scripts at run time. Why it is wrong: security, memory and load-context issues. Do instead: constrained rules engines or sandboxed processes.

8. Writing tools when analysers fit

What happens: one-off scripts for rules that should be enforced continuously. Why it is wrong: rules drift after the script runs. Do instead: analysers for ongoing enforcement.

🏛 Senior insight: trade-offs

Roslyn knowledge turns large, risky changes into automated, reviewable ones.

Need Roslyn tool
Enforce a rule in every build Analyser (with code fix)
Generate boilerplate at build time Incremental source generator
One-off migration across a repo Console tool with MSBuildWorkspace and a rewriter
Run user-defined rules at run time Compile to a collectible load context

Seniors also know the cost: compilations are memory-heavy, so tools should process projects one at a time.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions analysers built on the semantic model Why: Checks analyser authoring
    How to answer

    An analyser is a class deriving from DiagnosticAnalyzer with [DiagnosticAnalyzer(LanguageNames.CSharp)]. It declares DiagnosticDescriptors (id, title, message, category, default severity) and in Initialize calls EnableConcurrentExecution(), ConfigureGeneratedCodeAnalysis(...) and registers callbacks such as RegisterSymbolAction, RegisterSyntaxNodeAction or RegisterOperationAction, reporting with context.ReportDiagnostic. It targets netstandard2.0 like a generator. Test it with Microsoft.CodeAnalysis.CSharp.Analyzer.Testing using markup such as {|DEMO001:Run|}. Ship it as a project reference with OutputItemType="Analyzer" or a NuGet package, and let teams tune severity in .editorconfig.

  2. When: Candidate mentions generators built on Roslyn Why: Checks source generator authoring
    How to answer

    A source generator is a class marked [Generator] that implements IIncrementalGenerator (the older ISourceGenerator is deprecated). It runs inside the compiler, reads the user's code through Roslyn and adds new C# files to the compilation; it cannot modify existing code. The generator project targets netstandard2.0, references Microsoft.CodeAnalysis.CSharp (a version no newer than your consumers' compilers) with PrivateAssets="all", and sets EnforceExtendedAnalyzerRules. Consumers reference it as an analyser (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), or you pack it into analyzers/dotnet/cs in a NuGet package. Use RegisterPostInitializationOutput for marker attributes and EmitCompilerGeneratedFiles to see the output.

  3. When: Candidate mentions the lowering phase Why: Checks what lowering produces
    How to answer

    Roslyn parses C# into syntax trees, binds names and types into a semantic model, then lowers high-level constructs into simpler ones before emitting IL. foreach over an array becomes an index loop; over `List<T>` it uses the struct enumerator in a try/finally; using becomes try/finally with Dispose; lock becomes Monitor.Enter(obj, ref lockTaken) / Monitor.Exit (or Lock.EnterScope() for System.Threading.Lock); a + b + c on strings becomes one string.Concat; a switch on strings may become a length or hash-based jump. async methods and iterators become state machines. Roslyn does little optimisation; the JIT does the heavy work.

🛠 Real-world problems: how you handle them

Automated migration of a deprecated API across 3,000 files
Situation
A monorepo used a deprecated logging overload in [about 3,000] files before a library upgrade.
Signals
A regex-based attempt changed comments and unrelated methods and broke the build.
Investigation
Identified that only certain overloads needed changing, which required semantic information.
Resolution
Wrote a console tool with `MSBuildWorkspace`, used `GetSymbolInfo` to find the exact overloads and a `CSharpSyntaxRewriter` to replace them, preserving trivia.
Prevention
Added an analyser that flags the deprecated overload to stop new usages.

How to tell it (STAR)

Before [the logging upgrade], we had [about 3,000] call sites to change. Regex failed, so I wrote a Roslyn tool that used the semantic model to find exact overloads and rewrote them. The diff was clean and reviewable, and I added an analyser so the old API could not return.

↩ Usually asked after

More in Compilation

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me