7.0 KiB
Custom Languages (Bring Your Own Language)
code-review-graph ships parsers for 30+ languages, but the tree-sitter-language-pack it depends on bundles many more grammars than the built-in list. If your repo uses a language the graph does not cover yet — Erlang, Haskell, OCaml, Fortran, Ada, Clojure, ... — you can teach the parser about it with a small config file. No fork, no code changes.
Quick start
Create <repo_root>/.code-review-graph/languages.toml:
[languages.erlang]
extensions = [".erl"]
grammar = "erlang"
function_node_types = ["function_clause"]
class_node_types = ["record_decl"]
import_node_types = ["import_attribute"]
call_node_types = ["call"]
comment = "Erlang via the bundled tree-sitter-erlang grammar"
Then rebuild:
uv run code-review-graph build
Files matching the configured extensions are now parsed with the named
grammar, and the resulting Function/Class nodes and CALLS/IMPORTS_FROM edges
flow through every downstream feature (impact radius, search, communities,
wiki, MCP tools) exactly like built-in languages. Nodes carry the custom
language name (here erlang) in their language field.
Schema reference
Each custom language is one [languages.<name>] table.
| Key | Type | Required | Meaning |
|---|---|---|---|
<name> |
table key | yes | Language identifier stored on every parsed node. Lowercase letters, digits, _, -; max 32 chars; must start with a letter. |
extensions |
list of strings | yes | File extensions to claim, each starting with a dot (e.g. ".erl"). Matched case-insensitively. |
grammar |
string | yes | A grammar name shipped by tree_sitter_language_pack (probe availability — see below). |
function_node_types |
list of strings | no* | Tree-sitter node types that define functions/methods. Matching nodes become Function nodes (or Test nodes when the name/file looks like a test). |
class_node_types |
list of strings | no* | Node types that define classes/records/types. Matching nodes become Class nodes. |
import_node_types |
list of strings | no* | Node types for import/include statements. Each yields an IMPORTS_FROM edge. |
call_node_types |
list of strings | no* | Node types for call expressions. Each yields a CALLS edge from the enclosing function. |
comment |
string | no | Free-form note for humans; ignored by the parser. |
* At least one of the four node-type lists must be non-empty, otherwise the entry is skipped (there would be nothing to extract).
Validation rules (safety first)
The loader never crashes a build. Anything invalid is skipped with a
WARNING log line:
- Built-ins always win. A custom language cannot claim a built-in
extension (
.py,.ts,.ex, ...) and cannot reuse a built-in language name (python,elixir, ...). grammarmust load fromtree_sitter_language_pack; unknown grammars are skipped.- Every extension must start with a dot.
- Two custom languages cannot claim the same extension (first one wins).
- At most 20 custom languages are loaded per repo.
- Malformed TOML disables custom languages for that build (with a warning).
Finding the right node type names
Node type names are grammar-specific, so you need to look at the tree the grammar actually produces. Two easy options:
Option 1 — tree-sitter playground. Paste a snippet into https://tree-sitter.github.io/tree-sitter/7-playground.html and read the node names off the parse tree (select the matching grammar first).
Option 2 — probe locally with Python. The exact grammar version your
build uses is the one in tree_sitter_language_pack, so probing locally is
the most reliable source of truth:
uv run python - <<'EOF'
import tree_sitter_language_pack as tslp
source = b"""
-module(math_utils).
add(A, B) -> helper(A) + B.
helper(X) -> X * 2.
"""
def dump(node, depth=0):
print(" " * depth + node.type, node.text.decode()[:40].replace("\n", " "))
for child in node.children:
dump(child, depth + 1)
dump(tslp.get_parser("erlang").parse(source).root_node)
EOF
Pick the node types that wrap whole definitions (function_clause, not the
inner atom) and whole call expressions (call, not the callee identifier).
Worked example: Erlang end to end
src/math_utils.erl:
-module(math_utils).
-export([add/2, scale/2]).
-import(lists, [map/2]).
-record(point, {x, y}).
add(A, B) ->
helper(A) + B.
helper(X) -> X * 2.
scale(Points, F) ->
lists:map(fun(P) -> add(P, F) end, Points).
With the [languages.erlang] config from the quick start, a build produces:
Functionnodesadd,helper,scale(fromfunction_clause), each withlanguage = "erlang".- A
Classnodepoint(fromrecord_decl). CALLSedgesadd → helperandscale → add, resolved to their same-file qualified names, plusscale → lists:mapfor the remote call.- An
IMPORTS_FROMedge targetinglists(fromimport_attribute). CONTAINSedges from the file to every definition.
How extraction works (and its limits)
Custom languages run through the same generic tree-sitter walker as built-in languages — there is no per-language code path to maintain. That keeps the feature simple, but the generic heuristics have limits:
- Name extraction uses the default name-field heuristics. The walker
looks for a child node of a common identifier type (
identifier,name,type_identifier, ...) and falls back to the grammar'snamefield (node.child_by_field_name("name")). Grammars that store definition names in another shape (e.g. nested two levels deep with a non-standard field) will produce unnamed — and therefore skipped — definitions. - Callee extraction probes common field names (
function,callee,expr,name) and descends through curried applications. Exotic call shapes may be missed. - Import targets come from the grammar's
module/name/path/sourcefield when present, otherwise the raw statement text is recorded. - No cross-file module resolution. Import edges keep the module name as
written (e.g.
lists); they are not resolved to file paths the way built-in languages with dedicated resolvers are. - No language-specific extras: things like decorator-based test detection, framework annotations (Spring, Temporal), or SFC handling only exist for built-in languages.
If a language needs deeper support than the generic walker can give, please open an issue — config-driven support is the on-ramp, not the ceiling.
Troubleshooting
- Run a build with
-v/logging enabled and look forlanguages.tomlwarnings — every skipped entry says exactly why it was skipped. - Probe grammar availability:
uv run python -c "import tree_sitter_language_pack as t; t.get_language('erlang')"(raisesLookupErrorif the grammar is not bundled). - The config is read when a parser is constructed (every
build/update), so config changes take effect on the next build — re-runuv run code-review-graph buildafter editing.