The Concrete Syntax Tree
A tree in which every byte of the source appears exactly once — whitespace, comments and the literal text of every token included. It is what you need the moment a tool has to write code back out rather than only read it.
Why do IDEs and refactoring tools build a second, bigger tree when the compiler already has one?
A full-fidelity tree: the parse tree, with every token carrying its exact source text and its surrounding *trivia* — whitespace, newlines, comments, and in some designs preprocessor directives and disabled code regions. The defining property is the round trip: concatenating the tree's leaves in order reproduces the input file byte for byte. That is the invariant that makes the tree usable for writing, and it is exactly what [[parse-tree-vs-ast]] says an AST gives up.
A CST is entitled to assume only that the lexer produced tokens covering the whole input, including the parts an ordinary compiler discards. It must maintain the round-trip invariant under every operation: any edit that replaces a subtree must leave the concatenation of leaves equal to the intended new source, or the tree has silently stopped describing the file. A tool may rewrite a node only if the replacement preserves the trivia that belonged to the removed node, or deliberately relocates it — dropping it is a defect, and it is the specific defect that shows up as "the refactoring deleted my comments".
Key points
- A concrete syntax tree preserves every byte of the source, including whitespace, comments and exact token text, so printing its leaves reproduces the file.
- Comments and blank lines are *trivia* attached to adjacent tokens as leading or trailing, not nodes in their own right.
- Which token owns a given comment is an arbitrary documented convention, and it is why refactorings occasionally move a comment to the wrong place.
- Roslyn's red-green trees make full fidelity affordable by sharing immutable subtrees between versions and computing positions lazily.
- tree-sitter reaches the same goal by incremental reparsing of the edited region, with error nodes so a broken file still yields a complete tree.
- A CST has several times the nodes of an AST, so compilers keep the AST for analysis and build the CST only where source must be written back.
- The dividing line is direction: reading tools want an AST, writing tools need a CST.
- Toolchains that keep both generate one from a grammar description, because two hand-maintained definitions of the same language drift.
What the AST threw away, and who needs it back
An AST is a compression. It keeps the structure that determines meaning and drops everything else: parentheses that only grouped, the exact spelling of a numeric literal, the choice between ' and ", blank lines, and comments. For a compiler that is unambiguously the right design — every discarded thing is by definition irrelevant to what the program does, and the smaller tree makes every later pass simpler.
It is the wrong design for any tool that has to produce source code as output. A formatter must preserve comments. A refactoring engine must produce a file that differs from the original only where the refactoring applied. A codemod must not reflow a thousand unrelated lines. An IDE's "extract method" must leave the rest of the file byte-identical, or the diff is unreviewable. And a syntax highlighter must classify every character, including the ones between tokens.
So a second tree exists, and the rule of thumb is simple: a tool that only reads the program wants an AST; a tool that writes the program back out wants a CST. Most modern language toolchains build the CST first and derive the AST from it, rather than the reverse, because the derivation only goes one way.
| Question | Token stream | AST | CST |
|---|---|---|---|
| What does this program mean? | No | Yes — this is what it is for | Yes, after ignoring trivia |
| Where exactly is this construct? | Yes | Only if spans were threaded through | Yes, inherently |
| What comment documents this function? | Yes, if trivia was kept | No — discarded | Yes |
| Reproduce the file byte for byte | Yes, if trivia was kept | No | Yes — this is the defining property |
Was this literal written as 0x10 or 16? | Yes | Usually no; the value was parsed | Yes |
| Rewrite one expression and leave the rest untouched | Awkward — no structure | No — reprinting reformats everything | Yes |
| Cheap to traverse in an optimizer | n/a | Yes | No — far more nodes, most of them noise |
Trivia, and the question of who owns a comment
The mechanism is straightforward: the lexer, instead of skipping whitespace and comments, collects them and attaches them to an adjacent token as leading or trailing trivia. Roslyn — Microsoft's C# and Visual Basic compiler platform — introduced the terminology most people use, and its rule is representative: trivia up to and including the end of a line attaches as trailing trivia to the token before it; everything after that attaches as leading trivia to the token that follows.
That rule is arbitrary and it has to be. Consider a comment on its own line between two functions: it is documentation for the second one nine times out of ten, and a closing remark about the first one occasionally. No parser can tell. So the tool commits to a convention, documents it, and every refactoring engine built on it inherits the convention — which is why "move this function" sometimes takes the wrong comment with it in every IDE you have used.
Two design points are worth naming because they distinguish serious implementations. First, whether disabled preprocessor regions are in the tree: Roslyn keeps #if branches that were not taken as trivia, so a refactoring can see and update code the compiler never compiled. Second, whether the tree is *lossless in the presence of errors*: tree-sitter and rust-analyzer both produce a complete tree for broken input, with error nodes covering the parts that did not parse, precisely so that an editor can keep working while you type. A tree that only exists for valid programs is not usable in an editor at all — see [[language-server]].
Read it asThe comment is not a node of its own; it is trivia hanging off the fn token of the second function. That is why moving second moves the doc comment, and also why a comment written as a *closing* remark about first will be moved away from it. The convention is a choice, it is documented, and it is wrong roughly one time in ten — which is as good as any parser can do without reading the author's mind.
Two real designs: Roslyn's red-green trees and tree-sitter
rowan adopts with different memory tradeoffs (interned green nodes, dynamically typed nodes plus a typed API layer on top); tree-sitter's incremental GLR is a different answer aimed at language-agnostic editor tooling and deliberately produces a tree with no name resolution or types at all. TypeScript's compiler, meanwhile, keeps a full-fidelity tree without a red-green split and reuses nodes through a document registry. All four are in production; none of them is the standard.Both solve the same problem — a full-fidelity tree that can be edited cheaply while you type — and they solve it differently enough to be worth knowing as a pair.
Roslyn uses *red-green trees*. The green tree is immutable, has no parent pointers and stores only widths rather than absolute positions, which means an identical subtree can be shared between many places and between many versions of a file. The red tree is a thin lazily-created facade over it that adds parent links and absolute positions, computed by walking down from the root. The consequence is the useful part: editing one token produces a new green tree that shares every unchanged subtree with the old one, so a keystroke costs work proportional to the depth of the edit rather than to the size of the file. rust-analyzer uses the same design in its rowan library, and Swift's SwiftSyntax is a close relative.
tree-sitter takes the incremental-parsing route: a GLR-family parser that, given the previous tree and a list of edits, reparses only the affected region and reuses the rest of the tree directly. It is language-agnostic — grammars are separate artifacts — it produces a complete tree with error nodes for broken input, and it exposes a query language for matching patterns in the tree, which is what makes it the substrate for syntax highlighting, structural search and code navigation in editors that do not have a full language server for every language.
The shared insight is that structure sharing is what makes a full-fidelity tree affordable. The CST is several times larger than the AST, and rebuilding it on every keystroke would be hopeless; keeping unchanged subtrees identical between versions is what turns an expensive representation into a cheap one.
What it costs, and when not to build one
A CST has several times the node count of the equivalent AST, most of them punctuation, and every one of them costs allocation, cache footprint and traversal time. A compiler pass that pattern-matches on Binary(Add, l, r) becomes a pass that must skip past token nodes for the operator and possibly parentheses. That is why compilers that do not need to emit source keep the AST as the working representation and build a CST only in the tooling layer — or, like Clang, keep a rich AST that retains enough source information for most tooling purposes without going to full fidelity.
The other cost is duplication. A project with both a CST and an AST has two definitions of the language's structure, and they must agree. Toolchains solve this by deriving one from the other automatically (rust-analyzer generates its typed AST accessors from the grammar over rowan nodes; Roslyn generates its node classes from an XML description), which turns the problem into a code-generation problem rather than eliminating it.
The honest guidance: if the tool only reads, do not pay for it. If the tool writes source, you need it and there is no shortcut — every attempt to reprint from an AST plus "some" position information ends up rebuilding a worse CST incrementally, with comments going missing in the meantime.
- Need a CST: formatter, refactoring engine, codemod, IDE quick fix, syntax highlighter, linter with autofixes, migration tool.
- AST is enough: type checker, optimizer, interpreter, code generator, most analyses that only report.
- The dividing line is whether the output is source code that a human will read a diff of.
- Structure sharing between versions is what makes the larger tree affordable in an interactive setting.
- Error nodes are not optional in a tool that runs while the user is typing; a tree that requires valid input is unusable there.
How it works
The steps, in the order the compiler takes them.
- The lexer emits every token with its exact text, and collects the whitespace and comments between tokens instead of discarding them.
- Trivia is attached to tokens by a documented rule — typically trailing up to end of line, leading thereafter — so that no character of the input is unowned.
- The parser builds a node for every grammar production and every token, including punctuation, giving a tree whose leaves concatenate back to the source.
- Nodes store widths rather than absolute offsets, so an insertion earlier in the file does not require rewriting positions throughout the tree.
- Immutable nodes are interned and shared, so two versions of a file after a small edit share almost all of their structure.
- A lazily-created facade layer adds parent pointers and absolute positions on demand, computed by descending from the root and accumulating widths.
- An edit produces a new tree by rebuilding only the spine from the changed token to the root, reusing every sibling subtree unchanged.
- A typed AST view is generated over the CST, giving analysis code the convenient shape while the underlying nodes retain full fidelity.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A refactoring moves a declaration and its doc comment stays behind — or a closing comment about the previous item is dragged along with the moved one.
- A codemod rewrites one expression and the tool reprints the whole file, producing a diff of two thousand lines in which the actual change is invisible.
- A tool built on an AST silently deletes every comment in the files it touches, and nobody notices until a reviewer asks where the documentation went.
- An editor becomes sluggish on large files because the CST is rebuilt from scratch on each keystroke instead of sharing unchanged subtrees.
- A tree that requires a valid parse produces nothing at all for the seventy percent of the time the file is mid-edit, so completion and highlighting flicker off while typing.
- A hand-maintained AST and CST drift apart, and a construct added to the grammar is invisible to half the tooling, with no error anywhere.
When it helps
- Any tool that emits source: formatters, refactoring engines, codemods, migration tools, quick fixes, and generated-code updaters.
- Editor features that must work on invalid input — highlighting, folding, selection expansion, structural navigation — where error-tolerant full fidelity is the whole requirement.
- Structural search and replace, where matching on the tree rather than on text avoids every string-and-comment false positive that text search produces.
- Preserving reviewability: a change that touches only what it meant to touch is the difference between a codemod that can be landed and one that cannot.
When it hurts
- In the compiler's own analysis and optimization passes, where the extra nodes are pure overhead and every pattern match has to skip punctuation.
- In memory-constrained settings, since full fidelity for a large project is a multiple of the source size held live, and an IDE holds many files at once.
- When the tool genuinely only reads: building a CST for a type checker or an interpreter buys nothing and costs traversal in every pass.
- As a hand-maintained second definition of the language, which drifts from the first unless it is generated.
What it costs
Every one of these is paid by something.
- Full fidelity buys the round-trip property and pays several times the node count, in allocation, cache footprint and traversal cost for every consumer.
- Structure sharing buys cheap incremental edits and pays immutability: nodes cannot be mutated in place, so every change allocates a new spine and the API is less convenient.
- Lazy absolute positions buy cheap edits far from the root and pay a walk from the root the first time any node's position is needed.
- Keeping disabled preprocessor regions in the tree buys refactorings that see code the compiler did not compile, and pays a larger tree plus rules for a region with no valid parse.
- Generating the typed AST layer from a grammar buys consistency between the two views and pays a build-time code-generation step that everyone touching the grammar must learn.
- Error-tolerant parsing buys a usable tree while typing and pays a parser that is significantly harder to write and test than one that may simply fail — see
[[error-recovery]].
What else you could do
What a different compiler or language does instead, and when that is better.
- Keep an AST plus source spans and re-read the original text for anything you need to preserve, splicing edits into the byte buffer rather than reprinting. Simple, works well for small localised edits, and falls apart once edits move code around.
- Keep comments as real nodes in the AST attached to declarations, which is what many documentation tools do. Simpler than trivia, and it cannot represent a comment in the middle of an expression.
- Use a token stream plus offsets and do purely textual edits guided by structural queries — what
combyandast-grepeffectively do. Cheap, and it cannot answer semantic questions. - Use a projectional editor where source is stored as a tree and layout is a rendering concern, so trivia never exists. Solves the problem completely, and gives up plain-text tooling.
- Clang's middle path: a rich AST that retains source locations and enough token information for
clang-tidyandclang-format, with a separate token-level view where full fidelity is needed. Less uniform than a true CST, and it avoids the second tree.
See it for yourself
The flag, dump or tool that shows you this directly.
- tree-sitter:
tree-sitter parse file.jsprints the tree with byte ranges,tree-sitter parse --debugshows the parse actions, and the online playground renders the tree against the source interactively.tree-sitter queryruns pattern queries over it. - Roslyn: the Syntax Visualizer window in Visual Studio, or SharpLab (sharplab.io) with the "Syntax Tree" output selected — both show tokens and trivia explicitly. In code,
SyntaxFactory.ParseSyntaxTree(src).GetRoot().ToFullString()returning the original text is the round-trip property in one line. - rust-analyzer: the "Show Syntax Tree" command in VS Code prints the
rowantree for the current file, includingWHITESPACEandCOMMENTnodes. - TypeScript:
typescriptlang.org/playwith the AST viewer, orts.createSourceFile(...)plusnode.getFullText()versusnode.getText()— the difference between the two is exactly the leading trivia. - Python:
ast.parsedrops comments entirely, which you can verify withast.unparse;libcstandparsoare the full-fidelity alternatives and exist precisely because of that gap. - The one-line test for any of them: parse a file, print the tree back out, and
diffagainst the original. If it is not byte-identical, it is not full fidelity.
Plausible wrong readings
Stated the way a confident engineer states them.
- "A parse tree and a concrete syntax tree are different things." They are the same thing under two names, except that "concrete syntax tree" is usually used when trivia preservation is explicitly part of the contract.
- "Comments are stored as nodes in the tree." In every full-fidelity design worth copying they are trivia attached to a token, because a comment can appear between any two tokens and making it a node would require a slot everywhere.
- "The CST is what the compiler uses." Compilers overwhelmingly work on an AST. The CST exists for the tooling layer, and in many toolchains the compiler proper never sees it.
- "Full fidelity means you cannot change anything." It means every change is explicit. Editing is done by replacing a subtree and letting the unchanged siblings persist, which is precisely what makes minimal diffs possible.
- "If I keep spans on my AST nodes I have the same thing." Spans tell you where a node was. They do not tell you what was between two nodes, which is where every comment lives.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
A compiler's tree deliberately forgets everything that does not change what the program does — comments, blank lines, whether you wrote 16 or 0x10. That is fine when the tool only reads your code. It is fatal when the tool has to write it back out, because "format this file" and "rename this variable" must not delete your comments or reflow the rest of the file. So tools that emit source build a fuller tree in which every character of the file appears exactly once, and printing it out gives you the file back unchanged.
practical
If you are building a codemod or a migration tool, choose the full-fidelity parser for your language on day one: libcst rather than ast in Python, ts-morph or the TypeScript compiler API rather than a bare parser, rowan/syn with care in Rust, tree-sitter when you need many languages. And write one test first: parse a representative file, print it back, assert byte equality. Every comment-eating bug you will otherwise ship is caught by that single assertion, and it is the assertion that tells you whether the library you picked is actually full fidelity or only claims to be.
advanced
The design tension worth understanding is between fidelity and ergonomics, and the standard resolution is a two-layer architecture. The bottom layer is dynamically typed and lossless: nodes have a kind, a width and children, with no language-specific structure, which is what makes structure sharing, interning and error nodes tractable. The top layer is a generated, statically typed API — IfExpr::condition() returning an Option<Expr> — that gives analysis code the shape it wants while every accessor is a lookup into the untyped tree underneath. rust-analyzer and Roslyn both arrived at this independently, and the reason is instructive: a hand-written typed tree cannot represent broken input, and a purely untyped tree is miserable to write analyses against. Generating the typed layer from the grammar is what lets both properties coexist, and it turns "keep the two trees in sync" from a discipline problem into a build step.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
libcst, parso) had to build separate parsers for Python tooling rather than reusing the standard one.If you were asked this in an interview
- What is the defining property of a concrete syntax tree, and how would you test for it in one assertion?
- Where does a comment live in a full-fidelity tree, and who decides which node owns it?
- Why do Roslyn and rust-analyzer both use immutable, position-free nodes with a lazy facade on top?
Connections
- Programming Languages & Runtime Internals — Persistent data structures and structural sharing as a general techniqueRed-green trees are an application of persistent immutable structures: sharing unchanged subtrees between versions is the same idea that underlies persistent maps and vectors in functional runtimes. The general structure and its memory behaviour are owned there; what it buys a syntax tree — cheap keystroke-level edits and stable node identity across versions — is ours.