Skip to content

Repository files navigation

cpp-tree-sitter

... is a simple C++ and CMake wrapper around tree-sitter. This project provides CMake definitions and a C++ wrapper that help with

  • managing tree-sitter and tree-sitter grammars as dependencies
  • accessing basic tree-sitter APIs for parse tree inspection

There is also opt-in support for smoothing out C++ interactions with

  • formatters for tree-sitter wrapper types
  • queries
  • visitor and fold operations over tree-sitter trees

Requirements

... for version 0.1.0+ include

  • C++23 (gcc 14+, clang 18+ with libstdc++) (for std::expected, std::print, ...)
  • CMake 3.28+

Tag v0.0.3 should still support older versions of C++ and CMake.

Using in a CMake project

... requires the CPM CMake module for fetching and managing dependencies from github. Adding cpp-tree-sitter as a CPM dependency makes cpp-tree-sitter available as a library and provides a function, add_grammar_from_repo, that will download and make available a standard tree-sitter grammar on GitHub as a library.

The tree-sitter parser example can be reproduced in a CMake project with CPM by including the following in CMakeLists.txt:

include(cmake/CPM.cmake)
# Downloads this wrapper library and tree-sitter.# Makes them available via the `cpp-tree-sitter` CMake library target.CPMAddPackage(
NAMEcpp-tree-sitterGIT_REPOSITORYhttps://github.com/nsumner/cpp-tree-sitter.gitGIT_TAGv0.1.0
)
# Downloads a tree-sitter grammar from github and makes it available as a# cmake library target.add_grammar_from_repo(
NAMEtree-sitter-json# Defines the library name for a grammarREPOhttps://github.com/tree-sitter/tree-sitter-json.git# Repository URL of a tree-sitter grammarVERSION0.21.0# Version tag for the grammar
)
# Use the library in a demo program.add_executable(demo)
target_sources(demoPRIVATEdemo.cpp
)
target_link_libraries(demoPRIVATEtree-sitter-jsoncts::cpp-tree-sitter
)

Translating the parsing and tree inspection operations from the example to use the C++ wrappers then yields a demo.cpp like:

#include<cstdlib>
#include<print>
#include<string_view>
#include<cpp-tree-sitter.h>
#include<cts/format.h>extern"C" TSLanguage* tree_sitter_json();
intmain() {
auto parser = ts::Parser::create(tree_sitter_json());
if (!parser) {
std::println(stderr, "error: {}", parser.error());
returnEXIT_FAILURE;
}
constexpr std::string_view sourcecode = "[1, null]";
constauto tree = parser->parse(sourcecode);
if (!tree) {
std::println(stderr, "error: {}", tree.error());
returnEXIT_FAILURE;
}
const ts::Node root = tree->getRootNode();
std::println("root: {}", root.getType());
for (const ts::Node child : root.getNamedChild(0)->getNamedChildren()) {
std::println(" {}: '{}'", child, child.getSourceRange(sourcecode));
}
std::println("Syntax tree: {}", root.getSExpr());
returnEXIT_SUCCESS;
}

In particular, some of the underlying APIs now use method calls for easier discoverability, and resource cleaning is automatic.

Core functionality can be included with just <cpp-tree-sitter.h>, but extra features that might be more expensive are opt-in via:

  • <cts/format.h> - modern C++ formatters
  • <cts/query.h> - query processing
  • <cts/typed.h> - a type-dispatching visitor
  • <cts/loader.h> - dynamically loading grammar plugins

Examples

... can be found in the examples/ directory. Each one builds and runs, and each carries details and explanation in comments at the top of the file.

examples/CMakeLists.txt shows how to wire them up against cts::cpp-tree-sitter and a grammar fetched from github or against cts::cpp-tree-sitter-dynamic and a dynamically loaded grammar plugin.

Longer explanations that do not fit in an example live in docs/: build and install configurations, lifetimes and types, formatting, and loading grammars at run time.

Using the API

Iterating over children

... can be done with Node::getChildren() and Node::getNamedChildren() ranges. They return a ChildrenView, a lazy std::ranges::view over a node's children:

for (ts::Node child : root.getNamedChild(0)->getNamedChildren()) {
std::println(" {}: '{}'", child.getType(), child.getSourceRange(sourcecode));
}
auto newList = node.getChildren()
| std::views::filter(...)
| std::views::transform(...)
| std::ranges::to<std::vector>();

A ChildrenView holds its parent Node by value, so it is copyable and behaves intuitively.

Errors and lifetimes

... are managed through API types. Operations that can fail return std::expected<T, ts::Error>. Questions about whether something exists return std::optional<T>.

auto parser = ts::Parser::create(tree_sitter_json());
if (!parser) {
std::println(stderr, "error: {}", parser.error());
returnEXIT_FAILURE;
}

Tree and Parser own their tree-sitter resources and free them when destroyed. Node borrows from a Tree. Keep the owning Tree alive for as long as you hold Nodes from it.

docs/lifetimes-and-types.md lists which operations can fail, why, borrowing details.

Visiting, walking, and folding

... operations help with traversing and computing values from a parse tree.

ts::visit walks a subtree with a visitor object. Any object with onEnter() can be used as a visitor.

structCountNamed {
int count = 0;
ts::VisitAction onEnter(ts::Node node) {
count += node.isNamed() ? 1 : 0;
return ts::VisitAction::Continue; // or SkipChildren, or Stop
}
};
CountNamed counter;
ts::visit(root, counter);

An optional onLeave(ts::Node) fires on the way back out. visit is cursor-based, so it neither recurses nor allocates. For the same walk as a lazy range, ts::preorder composes with <ranges>:

auto numbers = ts::preorder(root)
| std::views::filter([](ts::Node n) { return n.getType() == "number"; });

ts::fold computes a value bottom-up instead, building each node's result from its children's results:

structMaxDepth {
intonNode(ts::Node node, std::span<int> children) {
int deepest = 0;
for (int depth : children) { deepest = std::max(deepest, depth); }
return node.isNamed() ? deepest + 1 : deepest;
}
};
MaxDepth folder;
int depth = ts::fold<int>(root, folder);

The fold result type is explicit at the call site. Leaves in the tree receive an empty children span. Unlike visit, fold allocates to keep a stack of child-result frames.

See examples/visitor_demo.cpp and examples/fold_demo.cpp.

Typed visitors

... with ts::typed enable dispatching to different methods based on the type of the node being visited.

#include<cts/typed.h>auto visitor = ts::typed(language,
ts::on<"number">([&](ts::Node node) { numbers.push_back(node); }),
ts::on<"comment", "string">([](ts::Node) {
return ts::VisitAction::SkipChildren; // one handler, several types
}),
ts::otherwise([](ts::Node) { /* everything else, punctuation included */ }));
if (!visitor) { /* error.name is the handler or node type at fault */ }
ts::visit(root, *visitor); // an ordinary TreeVisitor

Handler names are checked against the language when the visitor is built, so mistyped grammar labels fail up front. Supertypes are supported, and matching uses a lookup table that is most-specific-first. A visitor first tries the exact type, then the nearest supertype like _expression, then otherwise.

ts::typedFold<R> combines that dispatch with fold-style value production. Every handler returns an R, and child results are pulled on demand:

auto sum = ts::typedFold<int>(language,
ts::on<"number">([&](ts::Node node) { returntoInt(node); }),
ts::otherwise([](ts::Node node, ts::WalkFold<int>& walk) {
int total = 0;
for (ts::Node child : node.getNamedChildren()) { total += walk(child); }
return total;
}));
int result = sum->run(root);

A handler can also take a ts::Walk& and drive the traversal itself. examples/typed_demo.cpp shows more.

Formatting

... is opted into with <cts/format.h> and provides expected formatting for wrapper types.

#include<cts/format.h>// core types
#include<cts/query/format.h>// query types (extra opt-in for queries)

{} on a ts::Node renders as number [1, 2) and allocates nothing, so it is safe to use inside a visitor. docs/formatting.md has the full table of what renders as what.

Running queries

... is opted-into through <cts/query.h>. ts::Query::create compiles one Query against a language, and a ts::QueryCursor runs it:

#include<cts/query.h>auto query = ts::Query::create(
tree_sitter_json(),
"(pair key: (string) @key value: (_) @value)");
if (!query) { /* handle error */ }
// Resolve capture names to ids once ahead of time.auto key = query->getCaptureId("key");
ts::QueryCursor cursor;
auto matches = cursor.getMatches(*query, root);
if (!matches) { /* wrong language for this tree, or bad options */ }
for (const ts::QueryMatch& match : *matches) {
if (auto node = match.getNodeFor(*key)) {
std::println("key: '{}'", node->getSourceRange(source));
}
}

Three rules cover most uses:

  • A match and its captures are valid only until the cursor advances. Copy out whatever you want to keep.
  • Text predicates like #eq? and #match? are evaluated for you but need the source text. Pass {.source = source}. A #match? also needs a regex engine, which you can choose yourself and supply at create.
  • One cursor runs one query at a time. Use a separate cursor for each concurrent iteration.

A query over a large tree can be bounded with a progress callback, which tree-sitter invokes periodically during execution. Returning ts::ProgressAction::Cancel ends the execution with the results so far.

auto matches = cursor.getMatches(*query, root, {
.onProgress = [deadline](uint32_t/*byteOffset*/) {
returnstd::chrono::steady_clock::now() > deadline
? ts::ProgressAction::Cancel
: ts::ProgressAction::Continue;
}});
for (const ts::QueryMatch& match : *matches) { /* ... */ }
if (cursor.wasCancelled()) { /* ... */ }

examples/query_demo.cpp covers predicates. More advanced options are shown in examples/query_advanced_demo.cpp.

Building and installing

... depends on CPM as shown at the top of this README. docs/cmake.md covers alternative use cases like

  • building a grammar you already have on disk, with add_grammar_from_path
  • repositories holding several grammars, with SUBDIRECTORY
  • installing the library once and consuming it with find_package
  • linking against a system tree-sitter, with -DCTS_SYSTEM_TREE_SITTER=ON
  • providing your own tree-sitter target
  • building a grammar as a loadable module instead, with add_grammar_module

Loading grammars at run time

... allows you to reuse grammar plugins that you might have installed with tree-sitter or that might be used with an IDE.

#include<cts/loader.h>
ts::GrammarSearchPath const grammars{ts::layout::nvimTreesitter()};
auto language = grammars.load("json");
if (!language) { /* the error names every directory that was searched */ }

Support is opt-in and POSIX-only behind a second CMake target, cts::cpp-tree-sitter-dynamic. A loaded grammar is never unloaded, so the ts::Language stays valid for the lifetime of the process.

docs/dynamic-grammars.md shows APIs that can help to find and load a particular grammar, and examples/load_demo.cpp shows them in action.

About

Simple C++ and CMake wrapper around tree-sitter.

Resources

Contributing

Stars

46 stars

Watchers

3 watching

Forks

Releases

Packages

Contributors

Languages