Tree-sitter is an incremental parser. It is used for editor syntax highlighting,
structural code search, and code-aware chunking in retrieval pipelines. CocoIndex
uses it in its
RecursiveSplitter to
split source files on function and class boundaries rather than arbitrary line
counts. We trace it from grammar to parser to incremental re-parse, then show how
CocoIndex uses it for chunking.
What is Tree-sitter?
Tree-sitter, built by Max Brunsfeld, is the reference throughout. It is both a parser generator and a parsing library.
First, it is a parser generator. You write a grammar, the rules of a language’s
syntax, in JavaScript. One rule might say “a function is the keyword def, then
a name, then a parameter list, a colon, and a block.” Running tree-sitter generate turns every rule into a parser.c file: a big table of states, far
too large to read by hand. You compile it once and embed it.
Second, it is a fast, dependency-free parsing library. It runs that parser to
turn source code into a tree whose nodes mirror the grammar, and it updates the
tree incrementally as you edit, doing work proportional to the change rather than
re-reading the whole file. Feed it def greet(name): ... and you get
the tree on the right of the diagram, with def, the name, the parameters, and
the block each on their own node. (The Tree-sitter docs
have the canonical description.)
Four design goals shaped the engineering:
Parse any language
General enough to parse any programming language.
Parse on every keystroke
Fast enough to parse on every keystroke in a text editor.
Survive syntax errors
Robust enough to provide useful results even in the presence of syntax errors.
Embed anywhere
The runtime is pure C11, so it embeds in any application with no dependencies.
Each grammar lives in its own repository and ships its generated parser as C source, so adding a language to a host application is a matter of compiling and linking that parser. CocoIndex’s text engine, for example, statically links the grammars below:
tree-sitter grammars bundled into the engine- C
- C++
- C#
- Rust
- Go
- Swift
- Fortran
- Pascal
- Solidity
- Python
- Ruby
- PHP
- Java
- Kotlin
- Scala
- R
- Julia
- Elm
- Bash
- JavaScript
- TypeScript
- TSX
- HTML
- CSS
- Vue
- Svelte
- Astro
- JSON
- YAML
- TOML
- XML
- DTD
- SQL
- HCL
- CMake
- Markdown
Anything without a bundled grammar (or with no language specified) falls back to plain-text chunking instead of erroring.
Concrete syntax tree vs. abstract syntax tree
The tree Tree-sitter produces is a concrete syntax tree (CST), also called a parse tree. The distinction from an abstract syntax tree (AST) matters for how you use the output.
- An AST (the kind a compiler builds) throws away everything that does not
affect meaning: parentheses, commas, the
defkeyword, comments, whitespace. - A CST (what Tree-sitter builds) keeps all of it. Every byte of the source file is covered by exactly one leaf, and every node carries its precise byte range and row/column position.
That last property is what lets a tool map back to the original text, which highlighters, formatters, and chunkers all need to do.
Here is the CST for a small Python function:
def greet(name):
return f"hi {name}"
Notice that the keyword def, the parentheses, and the colon are all real
nodes. Tree-sitter calls the meaningful, addressable nodes “named” nodes
(function_definition, identifier, block) and the punctuation and keywords
“anonymous” nodes. You can walk only the named nodes when you want AST-like
structure, or all of them when you need to reconstruct text exactly.
How does the parser work?
A Tree-sitter grammar is a set of rules. Each rule names a syntactic construct and describes the sequences of tokens and sub-rules that form it. A trimmed fragment of the Python grammar looks like this:
function_definition: $ => seq(
'def',
field('name', $.identifier),
field('parameters', $.parameters),
':',
field('body', $._suite),
),
When you run tree-sitter generate, this grammar is compiled into a set of
state-machine tables. At runtime the parser reads tokens left to right and, at
each step, follows the classic LR parsing
shift/reduce loop:
Four mechanisms do the work:
- Shift pushes the next token onto a stack.
- Reduce fires when the tokens on top of the stack complete a grammar rule: the parser pops them off and replaces them with a single node.
- Lexing runs in the same pass. Tree-sitter generates the lexer from the string and regex tokens in the grammar.
- External scanners cover what a regular lexer cannot. A grammar can supply a hand-written scanner in C for tokens like Python’s indentation or heredocs.
Plain LR parsers reject any grammar with unresolved shift/reduce conflicts, and the grammars of real languages are full of them. Tree-sitter instead uses GLR parsing (Generalized LR, from Masaru Tomita’s work on natural-language parsing). When the table says more than one action is valid, the parser forks: it pursues every interpretation in parallel on a shared stack and discards the branches that fail to reach a valid parse. That is how one engine handles the ambiguous grammars of real languages without the grammar author resolving every conflict by hand.
Why is it incremental?
An editor re-parses on every keystroke, so re-reading the whole file each time would be wasteful. Instead, you tell Tree-sitter what byte range changed, and it re-parses only the parts of the tree the edit could have affected, reusing every unchanged subtree from the previous parse. The cost of an incremental re-parse scales with the size of the edit and the affected region, not with the size of the file. Editing one line in a ten-thousand-line file touches a handful of nodes near that line and leaves the rest of the tree untouched.
Mechanically: you call tree.edit() with the byte offsets of the change, then
parse again passing the old tree. Nodes whose byte ranges fall outside the edited
region keep their identity and their children, so a tool watching the tree can
diff old against new and update only what moved. For CocoIndex this is the same
philosophy we apply at the pipeline level with
incremental processing: do
work proportional to what changed, not to the size of the corpus.
How does it recover from syntax errors?
While you are typing, code is usually not valid yet: a bracket is unclosed, a statement is half-finished, an identifier is a prefix of the one you mean. A parser that stopped at the first error would be useless in an editor, so Tree-sitter recovers and keeps parsing instead.
When the parser reaches a token it cannot shift or reduce, it does not abort. It
inserts ERROR and MISSING nodes into the tree and resynchronizes so it can
keep parsing the rest of the file. The result is a tree that stays structurally
correct everywhere the source is correct. The damage is localized to the broken
region. A highlighter can still color the rest of the file; a chunker can
still find the function two definitions down. You can detect the broken regions
by checking node.has_error or node.is_missing and decide how to handle them,
rather than losing the entire parse.
What are queries?
Rather than walk the tree by hand, you can match patterns with Tree-sitter’s
small declarative query language, written as
S-expressions.
A query describes a shape of tree you want to find and captures the nodes you
care about with @-prefixed names:
(function_definition
name: (identifier) @function.name
parameters: (parameters) @function.params)
Run that against a file and you get back every function definition with its name
and parameter list captured. This is the mechanism behind editor features built
on Tree-sitter: syntax highlighting is a query that tags nodes with color
classes (highlights.scm), code folding and structural selection are queries,
and “go to symbol” is a query for definitions. Queries can also carry predicates
(for example, match an identifier only if its text equals a constant), which
keeps language-specific logic in declarative .scm files instead of host code.
How CocoIndex uses Tree-sitter to chunk code
How a file is split into chunks determines how well retrieval over it works. A fixed 1,000-character split cuts functions in half and separates a method from its class, leaving the embedding model fragments that mean little on their own. Splitting on the structure the parser already produces, the CST, avoids that.
CocoIndex’s RecursiveSplitter works this way. You give it the source text,
a target chunk size, and a language; it parses with Tree-sitter and walks the
resulting tree to find chunk boundaries that fall on real syntax nodes. The
language can be a name or a file extension, and a helper resolves it from a
filename:
from cocoindex.ops.text import RecursiveSplitter, detect_code_language
source = open("server.py").read()
language = detect_code_language(filename="server.py") # -> "python"
splitter = RecursiveSplitter()
chunks = splitter.split(source, chunk_size=1000, chunk_overlap=300, language=language)
for c in chunks:
print(c.start, c.end, len(c.text))
The algorithm is recursive descent over the tree, governed by size. It starts at the root and works downward:
Because every node carries its byte range, each chunk comes back with exact start and end positions, so a search result can point to the precise span in the original file. If the language is unsupported or unspecified, the splitter skips Tree-sitter entirely and treats the input as plain text. An unknown extension falls back to plain-text chunking instead of erroring, splitting on regex separators rather than syntax boundaries.
Why top-down
Top-down descent has two advantages over merging chunks up from tokens. First, it keeps each function or class whole instead of gluing fragments across boundaries. Second, its boundaries land on syntax-node boundaries, so editing one function re-chunks only that subtree and leaves its neighbors untouched.
That second property is what makes chunking ride on the same incremental machinery as the parser: stable boundaries mean a local edit causes a local re-chunk.
This pairs with incremental processing at the pipeline level: when one file in a repository changes, only that file is re-chunked and re-embedded, not the whole corpus.
Does syntax-aware chunking help retrieval?
cAST (Zhang et al., 2025), from CMU and Augment Code, measured this directly. It chunks code with the same recipe CocoIndex uses: parse with Tree-sitter, recursively split large nodes, and greedily merge small siblings up to a size budget. Against line- and character-based chunking, it improves Recall@5 on RepoEval (whether the right code lands in the top five retrieved chunks) by up to 4.3 points and Pass@1 on SWE-bench (whether a generated patch fixes the issue on the first try) by 2.67 points. The 4.3-point figure is the best case across the retrievers they tested; the others gained less.
Two findings shape how you set a splitter’s parameters:
- The merge step. Splitting without merging leaves tiny nodes (imports, single assignments) as their own thin chunks, which inflate the index and carry little context. In cAST’s ablation, removing the merge step dropped retrieval nDCG from 85.1 to 66.1. CocoIndex’s splitter runs the same merge, packing adjacent pieces back toward the target size.
- Budget by content, not lines. One line can hold a single brace or a 200-character expression, so a line budget drifts across files and languages. CocoIndex measures the budget in bytes; cAST counts non-whitespace characters. Either way, the budget should track real content. cAST found retrieval and generation peak with a budget around 2,000 to 2,500 characters.
Where this falls short
Syntax-aware parsing and chunking are not free wins everywhere:
- Tree-sitter gives you structure, not meaning. It does no name resolution, type inference, or cross-file linking, so a chunk can be syntactically whole and still miss the caller or the type definition it depends on.
- Each language is a separate grammar with its own maturity. Tokens a regular lexer cannot handle, like Python indentation or heredocs, rely on a hand-written C scanner, and a thin or lagging grammar yields a coarser tree.
- Keeping every token, with a byte range on every node, uses more memory than the pruned tree a compiler builds. That is fine for most indexing, but it adds up on very large files.
- The splitter respects boundaries only down to the smallest node that fits. A function larger than the budget, with no inner structure to descend into, falls back to regex separators inside it.
chunk_overlaprepeats text across neighboring chunks, which preserves continuity for retrieval but inflates the index.
Where to go next
If you want to see the chunker end to end, in a pipeline that indexes a codebase for retrieval and keeps the index live as files change, read Build Real-Time Codebase Indexing for AI Code Generation. For the parser internals, the Tree-sitter documentation is thorough and readable, and the per-language grammar repositories (for example tree-sitter-python) are a good way to see how a real grammar is written. For the retrieval side, cAST benchmarks AST-based chunking against line-based chunking and reports where the gains come from.
If CocoIndex’s code-aware chunking is useful to you, the project is open source at github.com/cocoindex-io/cocoindex.
Frequently asked questions.
What is Tree-sitter and what is it used for?
Tree-sitter is both a parser generator tool and an incremental parsing library. It compiles a language grammar into a fast C parser, builds a concrete syntax tree for a source file, and updates that tree efficiently as the file is edited. It powers syntax highlighting, structural code search, code folding, and code-aware chunking for retrieval pipelines.
See What is Tree-sitter?.
What is the difference between a concrete syntax tree and an abstract syntax tree?
An abstract syntax tree (AST), the kind a compiler builds, discards anything that does not affect meaning: parentheses, commas, keywords, comments, and whitespace. A concrete syntax tree (CST), which is what Tree-sitter produces, keeps all of it. Every byte of the source is covered by exactly one leaf, and every node carries its precise byte range and row/column position, so you can map back to the original text exactly.
How does Tree-sitter parse code?
A grammar (rules describing the language's syntax, written in JavaScript) is compiled with tree-sitter generate into state-machine tables. At runtime the parser is a table-driven automaton that reads tokens left to right and either shifts (pushes the next token) or reduces (replaces a completed rule with a single node). To handle the ambiguity in real programming languages, Tree-sitter uses GLR (Generalized LR) parsing: when more than one action is valid it forks and pursues every interpretation in parallel, discarding the branches that fail.
See How the parser works.
Why is Tree-sitter incremental, and why does that matter?
When a file is edited, Tree-sitter re-parses only the parts of the tree the edit could have affected and reuses every unchanged subtree from the previous parse. The cost scales with the size of the edit, not the size of the file, which is why editors can re-parse on every keystroke. The technique comes from Tim Wagner and Susan Graham's 1998 paper "Efficient and Flexible Incremental Parsing." In practice you call tree.edit() with the changed byte offsets, then parse again passing the old tree.
How does Tree-sitter handle syntax errors?
It does not give up. When the parser reaches a token it cannot shift or reduce, it inserts ERROR and MISSING nodes and resynchronizes so it can keep parsing the rest of the file. The resulting tree is structurally correct everywhere the source is correct, with the damage localized to the broken region. You can find the broken spans with node.has_error or node.is_missing. This robustness is a first-class design goal, which is what makes Tree-sitter usable in editors where code is broken most of the time.
See Error recovery.
What are Tree-sitter queries?
Queries are a declarative pattern-matching language, written as S-expressions, for finding shapes of tree and capturing the nodes you care about with @-prefixed names. Syntax highlighting, code folding, structural selection, and "go to symbol" are all implemented as queries (for example highlights.scm). Queries can also carry predicates, which keeps language-specific logic in declarative .scm files instead of host code.
See Queries.
How do you chunk code along syntax boundaries for RAG?
Parse the file with Tree-sitter and split on real syntax nodes instead of fixed character counts, so functions and classes stay intact. CocoIndex's RecursiveSplitter does this: starting from the root, a node that fits the target size becomes a chunk; a node that is too large is descended into and recursed (an oversized class splits into its methods first); leaf or terminal nodes fall back to regex separators. Adjacent small pieces are merged back toward the target size with overlap, and each chunk carries exact start and end byte positions.
How does CocoIndex chunk code with Tree-sitter in Python?
Use RecursiveSplitter from cocoindex.ops.text. Resolve the language from a filename with detect_code_language(filename=...), then call splitter.split(source, chunk_size=1000, chunk_overlap=300, language=language). The language can be a name ("python") or an extension (".py"). If the language is unsupported or unspecified, the splitter skips Tree-sitter and treats the input as plain text, so an unknown extension falls back to plain-text chunking instead of erroring.
Which programming languages does Tree-sitter support?
Each language has its own grammar repository that ships a generated C parser, so support is broad and grows independently of the core library. CocoIndex's text engine statically links grammars for more than 30 languages, including Python, Rust, TypeScript, Go, Java, and C++. Languages without a linked grammar fall back to plain-text chunking.
See What is Tree-sitter?.
Does AST or syntax-aware chunking actually improve code RAG?
Yes, and it has been measured. The cAST study (Zhang et al., 2025) chunks code the same way CocoIndex does, parsing with Tree-sitter and recursively splitting then merging nodes to a size budget, and benchmarks it against line- and character-based chunking. It reports retrieval Recall@5 on RepoEval improving by up to 4.3 points and code-fix Pass@1 on SWE-bench by 2.67 points. Its ablation also shows the merge step matters: removing it dropped retrieval nDCG from 85.1 to 66.1, because tiny nodes like imports become their own thin chunks.
Should chunk size be measured in lines, characters, or tokens?
Not lines: a single line can hold one brace or a 200-character expression, so a line budget drifts across files and languages. Measure by content instead. CocoIndex sizes chunks in bytes; the cAST study counts non-whitespace characters so indentation does not inflate the budget. Either way the budget is a proxy for the embedding model's token limit. cAST found retrieval and generation peak with a budget around 2,000 to 2,500 characters.