Adding a Language
RCE Code parses source with tree-sitter. Because every language-specific detail lives in data — a grammar wheel, a small declarative profile, and two query files — adding a language is a data-driven change: you do not touch the extractors, the indexer, or any other engine code.
There are five touchpoints:
- The grammar wheel — a PyPI package shipping the compiled tree-sitter grammar.
- A factory entry in
src/rce/code/parse/treesitter.pyso the parser pool can build the grammar. - A
LanguageProfileinsrc/rce/code/languages.py(namespace separator + comment node types). - A
<lang>.scmquery that captures definitions. - A
<lang>.refs.scmquery that captures references (calls + imports).
A sixth thing, detection (mapping a file to a language id), is usually already wired — you only add an entry if the file extension/name/shebang isn’t recognized yet.
Worked example: Ruby
1. Add the grammar wheel
tree-sitter grammars are distributed as standalone wheels. Add the dependency to [project] dependencies in pyproject.toml:
These wheels ship no type stubs, so also add the import name to the [[tool.mypy.overrides]] module list so mypy doesn’t complain about a missing stub:
Then install:
Each wheel exposes a zero-argument language() that returns the grammar capsule — e.g. tree_sitter_ruby.language(). A few wheels that bundle several grammars use a named variant instead (TypeScript’s wheel exposes tree_sitter_typescript.language_typescript), so check the wheel before wiring step 2.
2. Register the grammar factory
src/rce/code/parse/treesitter.py keeps a registry, _FACTORIES, mapping a language id to the zero-arg callable that produces the grammar capsule. Import the wheel and add an entry:
Register the callable, not its result
tree_sitter_ruby.language), not the result of calling it — the pool calls it lazily and caches the Language. This registry is exactly what ParserPool.supports(language) checks, so once your entry is here, parsing and querying for that language are live.3. Add a LanguageProfile
src/rce/code/languages.py holds the small declarative bits that can’t be expressed as queries. Add an entry to _PROFILES:
The fields (name mirrors the registry key):
qualified_separator— the string used to join nested names into a qualified name. Ruby namespaces with::(so a methodtotalinBilling::InvoicebecomesBilling::Invoice::total); Python/Java/Go/JS use".".comment_node_types— the grammar’s node-type name(s) for comments, consumed by the AST chunker. Ruby’s grammar uses a singlecommentnode type; some grammars split them (Rust and Java usefrozenset({"line_comment", "block_comment"})). Find the right names with the parse-tree snippet in §6.
4. Detection (usually already done)
src/rce/code/ingest/language.py maps a file to a canonical language id, in order: filename (_FILENAME) → extension (_EXT) → shebang (_SHEBANG). Ruby was already covered — .rb → ruby lives in _EXT and ruby → ruby in _SHEBANG — so Ruby needed no change here.
If your language’s extension isn’t recognized, add it to the relevant table. For example, to recognize a new .foo extension:
Add to _FILENAME for extensionless files matched by exact name (e.g. Dockerfile), or to _SHEBANG for interpreter names found in a #! line. The id you map to here must match the key you used in _FACTORIES, _PROFILES, and your .scm filenames.
5. Write the two .scm files
Both query files live in src/rce/code/parse/queries/ and are named after the language id: <lang>.scm (definitions) and <lang>.refs.scm (references). The extractors load them by that name; nothing else references them.
Definitions — ruby.scm
The convention consumed by src/rce/code/parse/symbols.py: capture the definition node with @def.<kind> and its name identifier with @name. Valid kinds (the suffix after def.) are:
function, method, class, interface, enum, type.
A useful shortcut: capture both free functions and methods as @def.function. The extractor walks each definition’s ancestors, and a @def.function whose enclosing definition is a class is auto-promoted to METHOD. So you don’t need a separate method pattern — just capture all defs as functions and let the ancestor walk reclassify the ones inside a class.
The shipped ruby.scm:
Note: both Ruby class and module are captured as @def.class (both are class-like namespaces), and both method and singleton_method (i.e. def self.create) are captured as @def.function — inside a class/module they auto-promote to METHOD, while a top-level def stays FUNCTION.
References — ruby.refs.scm
The convention consumed by src/rce/code/parse/refs.py: capture a callee with @ref.call and an imported name with @ref.import. The text of an @ref.import capture has its surrounding quotes/backticks stripped automatically, so capture the string literal directly.
Most languages have a distinct import/include node, so you write one pattern for calls and one for imports. Ruby doesn’t — require and require_relative are ordinary method calls, syntactically identical to any other call. To split them out we use tree-sitter predicates (#match? / #not-match?):
Three things make this work, and they’re the subtle part of the whole guide:
- Predicates are applied by this query path.
refs.pyruns the query throughQueryCursor(query).matches(...), which honors#match?/#not-match?. The first pattern emits every call except requires; the second emits the string argument of a require as an import. The two are mutually exclusive, so each require is bucketed exactly once and nothing is double-counted. - The
@_-prefixed helper capture. A predicate can only constrain a named capture, so to test the method name in the second pattern we must capture it — as@_req. But that capture is only a helper for the predicate; it is not a reference. By tree-sitter convention, a capture whose name starts with_is predicate-only scaffolding, andrefs.pyhonors that convention: it skips any capture whose name starts with_. So@_reqconstrains the predicate but is never emitted as a reference — only@ref.import(the string content) is. If you instead named it@ref.call, the require’s method name would leak out as a call reference. - The regex is anchored:
^require(_relative)?$. Anchoring matters. Without the^...$,#match?would also fire on a user-defined method likerequire_loginorrequired, wrongly treating it as an import (and, via the#not-match?in the first pattern, dropping it from the calls). With the anchors, only the exact namesrequireandrequire_relativeare routed to imports; everything else stays a normal@ref.call.
No predicates needed for a real import node
@ref.import pattern matching that node, and a @ref.call pattern for calls.6. Finding node types
You’ll need the grammar’s exact node-type names (for the profile’s comment types and for writing the queries). Parse a snippet and print the tree:
That prints the S-expression of the parse tree, showing every node type and field name (method, name:, identifier, constant, comment, ...). Iterate on your .scm against the same grammar until the captures are right:
This Query + QueryCursor(query).matches(root) flow is exactly what the extractors (and the tests below) use, so getting it green here means it’ll work in RCE Code.
7. Tests
Mirror the two Ruby test files. They feed inline source bytes through the public extractors and assert on the result. Create tests/unit/test_symbol_extract_<lang>.py and tests/unit/test_ref_extract_<lang>.py.
Symbols — assert names, kinds (including in-class method promotion), and qualified names:
References — assert the call/import bucketing, and (because Ruby uses an anchored predicate) that a user method merely named like require stays a call:
If a test enumerates the supported languages, add yours. For Ruby that was the single tuple in tests/unit/test_treesitter.py::test_supports_known_languages:
8. Verify
Run your new tests first, then the full gate:
That's the whole surface
See Supported Languages for the full matrix of what ships today and the documented per-language bounds.