Syntax Trees and the Semantic Model: Roslyn's Two Pillars
If you want to write analysers or source generators, you need to understand the two core abstractions that Roslyn exposes: the syntax tree and the semantic model. The syntax tree tells you what the code looks like. The semantic model tells you what it means.
The Syntax Tree
Roslyn parses every C# file into an immutable syntax tree — a full-fidelity representation of the source text, including whitespace and comments. Every token, trivia, and node is preserved.
You can inspect a syntax tree yourself by installing the .NET Compiler Platform SDK workload in Visual Studio, which gives you the Syntax Visualiser tool window. But you can also do it in code:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
var code = """
public class Greeter
{
public string Greet(string name) => $"Hello, {name}!";
}
""";
SyntaxTree tree = CSharpSyntaxTree.ParseText(code);
SyntaxNode root = tree.GetRoot();
From here you can walk the tree. Every construct in the language has a corresponding node type. A ClassDeclarationSyntax, a MethodDeclarationSyntax, a ParameterSyntax, and so on.
using Microsoft.CodeAnalysis.CSharp.Syntax;
var classDecl = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.First();
Console.WriteLine(classDecl.Identifier.Text); // "Greeter"
var method = classDecl.Members
.OfType<MethodDeclarationSyntax>()
.First();
Console.WriteLine(method.Identifier.Text); // "Greet"
Console.WriteLine(method.ReturnType); // "string"
Trivia
Syntax trees are full-fidelity, meaning whitespace, comments, and preprocessor directives are all retained as trivia attached to tokens. Leading trivia belongs to the token it precedes; trailing trivia belongs to the token it follows.
var firstToken = root.GetFirstToken();
foreach (var trivia in firstToken.LeadingTrivia)
{
Console.WriteLine($"{trivia.Kind()}: '{trivia}'");
}
This matters when you need to preserve formatting — for instance, when building a code fix provider that rewrites code.
The Semantic Model
Syntax alone cannot answer questions like "What type does this expression resolve to?" or "Which overload of ToString is being called here?" That is where the semantic model comes in.
To get a semantic model, you need a Compilation — Roslyn's equivalent of a complete build. A compilation brings together all the syntax trees, assembly references, and compiler options.
var compilation = CSharpCompilation.Create("Demo")
.AddReferences(MetadataReference.CreateFromFile(
typeof(object).Assembly.Location))
.AddSyntaxTrees(tree);
SemanticModel model = compilation.GetSemanticModel(tree);
Now you can ask semantic questions:
var methodSyntax = root.DescendantNodes()
.OfType<MethodDeclarationSyntax>()
.First();
// Get the symbol for the method
IMethodSymbol? methodSymbol = model.GetDeclaredSymbol(methodSyntax);
Console.WriteLine(methodSymbol?.ReturnType); // "System.String"
// Get the symbol for the class
var classSyntax = root.DescendantNodes()
.OfType<ClassDeclarationSyntax>()
.First();
INamedTypeSymbol? classSymbol = model.GetDeclaredSymbol(classSyntax);
Console.WriteLine(classSymbol?.ContainingNamespace); // (global namespace)
Symbols vs Syntax
This distinction is critical. A MethodDeclarationSyntax is a syntactic node — it represents the text of a method declaration. An IMethodSymbol is a semantic symbol — it represents the compiled meaning of that method, including its fully-resolved return type, parameters, and containing type.
When writing analysers, you will frequently move between these two worlds: find nodes with syntax queries, then resolve them through the semantic model to make decisions.
TypeInfo and SymbolInfo
Two particularly useful methods on SemanticModel:
// For expressions — what type does this resolve to?
var literal = root.DescendantNodes()
.OfType<LiteralExpressionSyntax>()
.First();
TypeInfo typeInfo = model.GetTypeInfo(literal);
Console.WriteLine(typeInfo.Type); // e.g., "System.String"
// For identifiers — what symbol does this refer to?
var identifier = root.DescendantNodes()
.OfType<IdentifierNameSyntax>()
.First();
SymbolInfo symbolInfo = model.GetSymbolInfo(identifier);
Console.WriteLine(symbolInfo.Symbol?.Kind); // e.g., Parameter
When to Use Which
- Syntax only: pattern-matching on code structure, enforcing naming conventions, checking for specific syntax patterns (like missing braces).
- Semantic model: type checking, resolving overloads, checking inheritance hierarchies, verifying attribute usage.
In analysers and source generators, you will almost always need both. The syntax tree finds candidates; the semantic model confirms whether they genuinely match your criteria. Get comfortable navigating both, and the rest of the Roslyn ecosystem becomes far more approachable.