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 classes
  • is_export_inside_function(node) — handle export visibility rules
  • extract_function_name(node) — non-standard name extraction
  • build_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.py with the SupportedLanguage enum value, file extensions, node-type sets, and optional tree-sitter queries
  • Define FQNSpec in language_spec.py with name extraction and module path mapping functions
  • Register LanguageSpec to 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_REGISTRY to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →