How to Add Support for a New Programming Language to the Code-Graph-RAG Parser System
Adding a new language to Code-Graph-RAG requires extending three tightly-coupled components: language constants in constants.py, language specifications in language_spec.py, and optionally a custom handler in parsers/handlers/.
The Code-Graph-RAG parser system is designed to ingest codebases, build graph representations, and support retrieval-augmented generation workflows. To add support for a new programming language—whether it's Rust, Go, Zig, or a domain-specific language—you need to integrate with its tree-sitter grammar and define how the parser extracts fully-qualified names (FQNs), detects scopes, and resolves imports. This guide walks through the complete implementation based on the actual source code in vitali87/code-graph-rag.
The Three Core Components of Language Support
Understanding the architecture helps you know where each change belongs. The system separates concerns into three layers:
| Component | Purpose | Location |
|---|---|---|
| Language constants | File extensions, node-type sets, tree-sitter queries | codebase_rag/constants.py |
| Language specifications | FQN extraction rules and module path mapping | codebase_rag/language_spec.py |
| Language handlers | Runtime quirks the generic base cannot handle | codebase_rag/parsers/handlers/<language>.py |
Most languages can reuse the generic BaseLanguageHandler. You only need a custom handler when the language has unique semantics for class methods, exports, or implicit parameters.
Step 1: Add Language Constants to constants.py
Start by extending the SupportedLanguage enum with your new language. Every constant that follows uses this enum value as its key.
# codebase_rag/constants.py
from enum import Enum, auto
class SupportedLanguage(Enum):
PYTHON = auto()
JS = auto()
RUST = auto()
# ... existing languages ...
FOO = auto() # Your new language
Next, define the file extensions and node-type sets. These must match the actual node type strings from the tree-sitter grammar:
# File extensions that trigger this language handler
FOO_EXTENSIONS = (".foo", ".foos")
# Node types that create a new naming scope (module, class, namespace)
FQN_FOO_SCOPE_TYPES = ("module", "class_definition", "namespace")
# Node types that define callable entities
FQN_FOO_FUNCTION_TYPES = ("function_definition", "method_definition")
# Parser node-type sets for entity detection
SPEC_FOO_FUNCTION_TYPES = frozenset({"function_definition", "lambda"})
SPEC_FOO_CLASS_TYPES = frozenset({"class_definition"})
SPEC_FOO_MODULE_TYPES = frozenset({"module"})
SPEC_FOO_CALL_TYPES = frozenset({"call", "invocation"})
SPEC_FOO_IMPORT_TYPES = frozenset({"import_statement", "import_from_statement"})
If your language needs custom tree-sitter queries to locate functions, classes, or call sites, add them as string literals:
FOO_FUNCTION_QUERY = """
(function_definition
name: (identifier) @name) @function
"""
FOO_CLASS_QUERY = """
(class_definition
name: (identifier) @name) @class
"""
FOO_CALL_QUERY = """
(call
function: (identifier) @name) @call
"""
These queries follow standard tree-sitter syntax with capture names (@name, @function) that the parser uses to extract identifiers.
Step 2: Create the FQN Specification
The fully-qualified name (FQN) system converts tree-sitter nodes into unique, hierarchical identifiers like mypackage.submodule.MyClass.my_method. In codebase_rag/language_spec.py, you define two critical functions:
_foo_get_name(node)— extracts the declared name from a node_foo_file_to_module(file_path, repo_root)— converts file paths to module parts
# codebase_rag/language_spec.py
from pathlib import Path
from tree_sitter import Node # type: ignore
from .constants import SupportedLanguage as cs
from .models import FQNSpec, LanguageSpec
def _foo_get_name(node: Node) -> str | None:
"""Extract the identifier name from a tree-sitter node."""
name_node = node.child_by_field_name("name")
if name_node and name_node.text:
return name_node.text.decode(cs.ENCODING_UTF8)
return None
def _foo_file_to_module(file_path: Path, repo_root: Path) -> list[str]:
"""Convert 'src/core/utils.foo' relative to repo root into ['src', 'core', 'utils']."""
try:
relative_path = file_path.relative_to(repo_root)
# Remove extension and split into path components
return list(relative_path.with_suffix("").parts)
except ValueError:
# File outside repo root — use filename only
return [file_path.stem]
With these helpers, construct the FQNSpec and register it:
FOO_FQN_SPEC = FQNSpec(
scope_node_types=frozenset(cs.FQN_FOO_SCOPE_TYPES),
function_node_types=frozenset(cs.FQN_FOO_FUNCTION_TYPES),
get_name=_foo_get_name,
file_to_module_parts=_foo_file_to_module,
)
LANGUAGE_FQN_SPECS[cs.SupportedLanguage.FOO] = FOO_FQN_SPEC
The FQNSpec dataclass (defined in codebase_rag/models.py) centralizes how the FQN resolver traverses the AST upward to build qualified names.
Step 3: Register the Language Specification
The LanguageSpec dataclass bundles all constants for runtime access. Populate it with the values defined in Step 1 and add it to LANGUAGE_SPECS:
LANGUAGE_SPECS[cs.SupportedLanguage.FOO] = LanguageSpec(
language=cs.SupportedLanguage.FOO,
file_extensions=cs.FOO_EXTENSIONS,
function_node_types=cs.SPEC_FOO_FUNCTION_TYPES,
class_node_types=cs.SPEC_FOO_CLASS_TYPES,
module_node_types=cs.SPEC_FOO_MODULE_TYPES,
call_node_types=cs.SPEC_FOO_CALL_TYPES,
import_node_types=cs.SPEC_FOO_IMPORT_TYPES,
import_from_node_types=cs.SPEC_FOO_IMPORT_TYPES,
# Optional: only if you defined custom queries
function_query=cs.FOO_FUNCTION_QUERY,
class_query=cs.FOO_CLASS_QUERY,
call_query=cs.FOO_CALL_QUERY,
)
The extension-to-spec mapping happens automatically at module import time via the loop at the bottom of language_spec.py:
for _config in LANGUAGE_SPECS.values():
for _ext in _config.file_extensions:
_EXTENSION_TO_SPEC[_ext] = _config
This ensures that discovering a .foo file immediately yields the correct LanguageSpec.
Step 4: Implement a Custom Handler (When Needed)
Many languages work with the default BaseLanguageHandler implementation. Create a custom handler only when you need to override:
is_class_method(node)— detect methods bound to classesis_export_inside_function(node)— handle export visibility rulesextract_function_name(node)— non-standard name extractionbuild_function_qualified_name(node, ...)— custom FQN construction logic
Create codebase_rag/parsers/handlers/foo.py:
from tree_sitter import Node # type: ignore
from .base import BaseLanguageHandler
from ... import constants as cs
class FooLanguageHandler(BaseLanguageHandler):
"""
Language-specific handler for Foo (.foo files).
Overrides default behavior for class method detection.
"""
def is_class_method(self, node: Node) -> bool:
"""
Foo uses an explicit 'self' parameter as first argument
to indicate instance methods, similar to Python.
"""
if node.type not in cs.FQN_FOO_FUNCTION_TYPES:
return False
# Check first parameter list child for 'self' identifier
for child in node.children:
if child.type == "parameters":
params = child.named_children
if params and params[0].type == "identifier":
return params[0].text == b"self"
return False
def is_export_inside_function(self, node: Node) -> bool:
"""
Foo allows 'export' statements inside functions for
dynamic module re-exports. Default handler assumes
exports are always module-level.
"""
parent = node.parent
while parent:
if parent.type in cs.FQN_FOO_FUNCTION_TYPES:
return True
parent = parent.parent
return False
The BaseLanguageHandler (defined in codebase_rag/parsers/handlers/base.py) provides sensible defaults for most operations. Study existing handlers like python.py and rust.py for patterns.
Step 5: Register the Handler in the Dispatch Registry
The parser selects handlers via a registry mapping SupportedLanguage values to handler classes. In codebase_rag/parsers/handlers/registry.py (or directly in function_ingest.py if no separate registry exists):
from .python import PythonLanguageHandler
from .rust import RustLanguageHandler
from .js_ts import JsTsLanguageHandler
from .foo import FooLanguageHandler # Your new handler
from ... import constants as cs
HANDLER_REGISTRY: dict[cs.SupportedLanguage, type[BaseLanguageHandler]] = {
cs.SupportedLanguage.PYTHON: PythonLanguageHandler,
cs.SupportedLanguage.JS: JsTsLanguageHandler,
cs.SupportedLanguage.RUST: RustLanguageHandler,
# ... existing entries ...
cs.SupportedLanguage.FOO: FooLanguageHandler,
}
The dispatch occurs in codebase_rag/parsers/function_ingest.py when processing each file:
from .handlers.registry import HANDLER_REGISTRY
def get_handler_for_file(file_path: Path, spec: LanguageSpec) -> BaseLanguageHandler:
handler_class = HANDLER_REGISTRY.get(spec.language, BaseLanguageHandler)
return handler_class()
Step 6: Test Your Implementation
Add test coverage in codebase_rag/tests/ to prevent regressions. The existing test suite provides language-agnostic infrastructure:
| Test File | Validates |
|---|---|
test_fqn_resolver.py |
FQN extraction for various AST patterns |
test_callable_flow_tracing_*.py |
Call graph construction |
Create a minimal test file in your new language:
# codebase_rag/tests/test_foo_parsing.py
from pathlib import Path
import pytest
from codebase_rag.language_spec import LANGUAGE_SPECS, get_spec_for_extension
from codebase_rag.constants import SupportedLanguage
def test_foo_extension_registration():
"""Verify .foo files map to the correct language spec."""
spec = get_spec_for_extension(".foo")
assert spec.language == SupportedLanguage.FOO
assert ".foo" in spec.file_extensions
def test_foo_fqn_resolution(tmp_path):
"""Test FQN extraction on a sample Foo file."""
sample_code = '''
module mymodule;
class MyClass {
function myMethod(self) {
helper();
}
}
'''
test_file = tmp_path / "src" / "mymodule.foo"
test_file.parent.mkdir(parents=True)
test_file.write_text(sample_code)
# Run through the actual parser pipeline
from codebase_rag.parsers.function_ingest import ingest_file
result = ingest_file(test_file, repo_root=tmp_path)
# Assert expected qualified names were extracted
fqns = {entity.fqn for entity in result.entities}
assert "src.mymodule.MyClass.myMethod" in fqns
Summary
Adding a new programming language to Code-Graph-RAG involves these key steps:
- Extend
constants.pywith theSupportedLanguageenum value, file extensions, node-type sets, and optional tree-sitter queries - Define
FQNSpecinlanguage_spec.pywith name extraction and module path mapping functions - Register
LanguageSpecto bundle all constants for runtime lookup - Create a custom handler only when the language has unique semantics the base handler cannot infer
- Add to
HANDLER_REGISTRYto enable dispatch based on file extension - Write tests using existing language-agnostic test infrastructure
The modular architecture separates tree-sitter grammar details from FQN resolution logic and runtime behavior, making most language additions straightforward configuration work.
Frequently Asked Questions
What tree-sitter grammar do I need for a new language?
You need a tree-sitter grammar compiled to a shared library or Python bindings that the parser can load. The codebase_rag system uses the standard tree-sitter Python API. Install the grammar package (e.g., tree-sitter-go, tree-sitter-zig) and ensure it's importable. The node type strings in your *_NODE_TYPES constants must exactly match the grammar's node type names.
Can I add a language without creating a custom handler?
Yes—most languages work with the default BaseLanguageHandler. You only need a custom handler when your language has unusual semantics: implicit self parameters, export visibility modifiers, name mangling, or non-standard scoping rules. Start without one, run the test suite, and add overrides only for failing cases.
How do I debug FQN resolution for my new language?
Use the test_fqn_resolver.py infrastructure with a minimal source file. Add print() statements to your _get_name and file_to_module_parts functions, or set breakpoints in codebase_rag/language_spec.py where LANGUAGE_FQN_SPECS is accessed. The FQN builder traverses parent nodes upward—verify your scope_node_types correctly identifies all nodes that introduce a new naming scope.
Why does my new language not trigger despite correct constants?
Check three things: (1) the file extension appears in LANGUAGE_SPECS[<language>].file_extensions, (2) the SupportedLanguage enum value is used consistently across all three files, and (3) Python has imported your new module so the LANGUAGE_SPECS[...] = assignment executes. The extension-to-spec mapping builds at import time—any import errors or circular dependencies prevent registration.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →