Skip to main content

Language Support

ctx uses tree-sitter for parsing, providing accurate symbol extraction across multiple programming languages. This page details what's extracted from each language and how relationships are tracked. Languages without a built-in grammar can be added declaratively through any stdio language server — see Languages beyond the built-in set (LSP).

Supported Languages Overview

LanguageExtensionsSymbol ExtractionEdge TrackingStatus
Rust.rsFullCalls, Implements, ImportsFull
TypeScript.tsFullCalls, Extends, Implements, ImportsFull
TSX.tsxFullCalls, Extends, Implements, ImportsFull
JavaScript.js, .mjs, .cjsFullCalls, Extends, ImportsFull
JSX.jsxFullCalls, Extends, ImportsFull
Python.py, .pyiFullCalls, Extends, ImportsFull
Go.goFullCalls, ImportsFull
C.c, .h when selected as CFullCalls, IncludesFull
C++.cc, .cpp, .cxx, .hh, .hpp, .hxx, .ipp, .tpp, .h when selected as C++FullCalls, IncludesFull
Zig.zigFullCalls, ImportsFull
Solidity.solFullCallsFull
YAML.yaml, .ymlFile tracking onlyN/APartial

Languages beyond the built-in set (LSP)

Any language with a stdio language server can be indexed by registering the server under [lsp.<language>] in .ctx/config.toml — Kotlin, Scala, Ruby, Zig, and so on. ctx then extracts symbols via textDocument/documentSymbol, call edges via the call-hierarchy requests, and resolves cross-file references via textDocument/definition:

[lsp.kotlin]
command = "kotlin-language-server"
extensions = ["kt", "kts"]
backend = "lsp"

For languages covered by the community registry, ctx lsp add <language> writes this block for you after showing what it would install. Server failures never break indexing: a missing or crashed server produces a stderr warning and ctx falls back to tree-sitter (built-in languages) or file-only records (dynamic languages).

See Add a language via LSP for the full key reference, and ctx lsp for the registry commands.

Hybrid mode for built-in languages

The built-in languages above can also be paired with a language server using backend = "hybrid" (the default for configured blocks). Tree-sitter still does the extraction — fast and offline — and the server is consulted only afterwards, to resolve cross-file references that static name resolution left unresolved or ambiguous (see Cross-File Resolution below). This tightens the call graph without giving up tree-sitter's speed:

[lsp.python]
command = "pyright-langserver"
args = ["--stdio"]
# backend = "hybrid" is the default; extensions default to ["py", "pyi"]

Without any [lsp.*] block, nothing changes: no server is ever spawned and indexing behaves exactly as described on this page.

Rust

Extracted Symbols

KindExampleNotes
Functionfn main()Top-level functions
Methodfn new(&self)Functions in impl blocks
Structstruct UserStruct definitions
Enumenum StatusEnum definitions
Traittrait DisplayTrait definitions
Type aliastype Result<T> = ...Type aliases
Constconst MAX: i32Constants
Staticstatic GLOBAL: &strStatic variables

Visibility Detection

  • pub -> public
  • pub(crate) -> crate
  • pub(super) -> super
  • No modifier -> private

Example

/// A user in the system.
pub struct User {
    pub id: u64,
    pub name: String,
}

impl User {
    /// Create a new user.
    pub fn new(name: &str) -> Self {
        Self { 
            id: generate_id(),
            name: name.to_string() 
        }
    }
    
    /// Validate the user.
    fn validate(&self) -> bool {
        !self.name.is_empty()
    }
}

trait Authenticate {
    fn verify(&self) -> bool;
}

impl Authenticate for User {
    fn verify(&self) -> bool {
        self.validate()
    }
}

Extracted symbols:

  • User (struct, public)
  • User::new (method, public)
  • User::validate (method, private)
  • Authenticate (trait)
  • User::verify (method, public via trait)

Extracted edges:

  • User::new calls generate_id
  • User::new calls to_string
  • User::verify calls validate
  • User implements Authenticate

Documentation Extraction

Rust doc comments (/// and //!) are extracted:

  • First line becomes the brief field
  • Full content becomes docstring

TypeScript

Extracted Symbols

KindExampleNotes
Functionfunction foo()Named functions
Arrow Functionconst foo = () => {}With const declaration
Classclass UserClass declarations
Methodauthenticate()Class methods
Interfaceinterface IUserInterface declarations
Type Aliastype UserId = stringType definitions
Enumenum StatusEnum declarations

Visibility Detection

  • export -> public
  • No export -> private
  • private keyword in class -> private
  • public keyword in class -> public
  • protected keyword in class -> protected

Example

/** User service for authentication. */
export interface UserService {
  authenticate(token: string): Promise<User>;
}

/** Default implementation. */
export class DefaultUserService implements UserService {
  constructor(private db: Database) {}
  
  async authenticate(token: string): Promise<User> {
    const decoded = decodeToken(token);
    return this.db.findUser(decoded.userId);
  }
  
  private validateToken(token: string): boolean {
    return token.length > 0;
  }
}

/** Decode a JWT token. */
export const decodeToken = (token: string): TokenPayload => {
  return jwt.decode(token);
};

type UserId = string;

Extracted symbols:

  • UserService (interface, public)
  • DefaultUserService (class, public)
  • DefaultUserService.authenticate (method, public)
  • DefaultUserService.validateToken (method, private)
  • decodeToken (function, public)
  • UserId (type, private)

Extracted edges:

  • DefaultUserService implements UserService
  • authenticate calls decodeToken
  • authenticate calls findUser
  • decodeToken calls jwt.decode

JSDoc Extraction

JSDoc comments (/** */) are extracted:

  • @description or first line -> brief
  • Full content -> docstring

JavaScript / JSX

Same as TypeScript, minus type-specific constructs (interfaces, type aliases, enums).

Additional JSX Support

// Function component
export function Button({ label, onClick }) {
  return <button onClick={onClick}>{label}</button>;
}

// Arrow function component
export const Card = ({ title, children }) => (
  <div className="card">
    <h2>{title}</h2>
    {children}
  </div>
);

Extracted:

  • Button (function, public)
  • Card (function, public)

TSX

Combines TypeScript and JSX support:

interface ButtonProps {
  label: string;
  onClick: () => void;
}

export const Button: React.FC<ButtonProps> = ({ label, onClick }) => {
  return <button onClick={onClick}>{label}</button>;
};

Extracted:

  • ButtonProps (interface, private)
  • Button (function, public)

Python

Extracted Symbols

KindExampleNotes
Functiondef foo():Top-level functions
Async Functionasync def foo():Async functions
Classclass User:Class definitions
Methoddef validate(self):Instance methods
Static Method@staticmethod def create():Static methods
Class Method@classmethod def from_dict(cls):Class methods
ConstantMAX_RETRIES = 3UPPER_CASE names at module level

Visibility Detection

  • Names starting with _ -> private
  • Names starting with __ (not __dunder__) -> private (name mangling)
  • All other names -> public

Example

"""User module for authentication."""

MAX_RETRIES = 3

class User:
    """A user in the system."""
    
    def __init__(self, name: str):
        """Initialize the user."""
        self.name = name
    
    def validate(self) -> bool:
        """Validate the user."""
        return len(self.name) > 0
    
    @staticmethod
    def from_dict(data: dict) -> "User":
        """Create a user from a dictionary."""
        return User(data["name"])
    
    def _internal_check(self):
        """Private method."""
        pass

class Admin(User):
    """Admin user with elevated privileges."""
    
    def __init__(self, name: str, permissions: list):
        super().__init__(name)
        self.permissions = permissions

async def fetch_user(user_id: int) -> User:
    """Fetch a user from the database."""
    data = await db.get(user_id)
    return User.from_dict(data)

Extracted symbols:

  • MAX_RETRIES (constant, public)
  • User (class, public)
  • User.__init__ (method, private - starts with _)
  • User.validate (method, public)
  • User.from_dict (method, public)
  • User._internal_check (method, private)
  • Admin (class, public)
  • Admin.__init__ (method, private)
  • fetch_user (function, public)

Extracted edges:

  • Admin extends User
  • Admin.__init__ calls super().__init__
  • User.from_dict calls User
  • fetch_user calls db.get
  • fetch_user calls User.from_dict

Docstring Extraction

Python docstrings (triple-quoted strings) are extracted:

  • First line -> brief
  • Full content -> docstring

Go

Extracted Symbols

KindExampleNotes
Functionfunc Handle()Top-level functions
Methodfunc (s *Server) Start()Methods with receivers
Structtype User struct {}Struct type definitions
Interfacetype Reader interface {}Interface type definitions
Constconst MaxRetries = 3Constants

Visibility Detection

  • Exported identifiers (capitalized first letter) -> public
  • Unexported identifiers (lowercase first letter) -> private

Example

package auth

// User represents an account in the system.
type User struct {
    ID   uint64
    Name string
}

// Authenticator verifies credentials.
type Authenticator interface {
    Verify(token string) (*User, error)
}

// NewUser creates a user with a generated ID.
func NewUser(name string) *User {
    return &User{ID: generateID(), Name: name}
}

func (u *User) validate() bool {
    return len(u.Name) > 0
}

Extracted symbols:

  • User (struct, public)
  • Authenticator (interface, public)
  • NewUser (function, public)
  • User.validate (method, private)

Extracted edges:

  • NewUser calls generateID

Documentation Extraction

Go doc comments (// immediately preceding a declaration) are extracted:

  • First line becomes the brief field
  • Full comment becomes docstring

C and C++

C and C++ files are fully indexed. Ambiguous .h files are parsed with both grammars; the tree with fewer error and missing nodes is selected, with C winning ties. The selected c or cpp language is stored in the index and is also used for structural fingerprints.

Definitions and prototypes, methods, constructors, destructors, operators, structs, classes, unions, enums, namespaces, typedefs, aliases, top-level variables/constants, and object- and function-like macros are searchable. C++ symbols receive ::-qualified names and syntactic namespace/class parent links. C static declarations and declarations in anonymous C++ namespaces are private. C++ class/struct access defaults and access specifiers are honored; protected maps to private because ctx has no protected visibility. Contiguous //////! comments and Doxygen blocks provide documentation.

Direct, member, qualified, and constructor syntax produces call edges. Quoted includes retain the unquoted path and resolve from the importing directory, repository root, or a unique indexed suffix. Angle includes retain <...> and resolve only when the enclosed path exactly matches an indexed repository path. System, missing, ambiguous, computed, root-escaping, and build-path-dependent includes remain unresolved; compile_commands.json, type inference, overload resolution, template instantiation, and preprocessor expansion are outside static analysis.

C/C++ functions participate in ctx duplicates; identifiers and literals are normalized and comments are removed.

Zig

Zig .zig files are fully indexed. (.zon files remain ordinary context content and are not parsed as Zig source.)

Extracted Symbols

KindExampleNotes
Functionfn run() voidNamed top-level functions
Methodfn start(self: *Server) voidFunctions in named containers
Structconst Server = struct {}Named struct initializers
Enumconst Mode = enum { fast }Named enum initializers
Typeconst Value = union(enum) { ... }Union, opaque, and error-set initializers
Const / Variableconst limit = 10, var count = 0Container-level declarations
Named testtest "parses input" {}Anonymous test blocks are skipped

pub declarations are public; other declarations are private. Contiguous /// comments become the symbol's docstring, with the first line used as its brief. Container fields are not indexed as symbols.

Identifier calls, field calls such as server.start(), and builtin calls such as @panic() produce call edges. Literal imports record their alias and specifier: const util = @import("../util.zig"). Imports ending in .zig are resolved relative to the importing file for architecture checks. Package imports such as @import("std"), missing files, dynamic imports, and paths escaping the index root remain unresolved.

Zig functions participate in ctx duplicates; identifiers and literals are normalized and comments are removed like the other Tree-sitter languages. Static analysis does not perform type inference, computed-call resolution, comptime expansion, or Zig package-manager dependency resolution.

Solidity

Extracted Symbols

KindExampleNotes
Contractcontract TokenContract definitions
Functionfunction transfer()Contract functions
Modifiermodifier onlyOwnerFunction modifiers
Eventevent TransferEvent definitions
Structstruct ProposalStruct definitions
Enumenum StatusEnum definitions
Errorerror UnauthorizedCustom errors

Visibility Detection

  • public -> public
  • external -> public
  • internal -> crate (treated as internal)
  • private -> private

Example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/// @title Token contract
/// @notice Implements ERC20-like functionality
contract Token {
    mapping(address => uint256) public balances;
    
    event Transfer(address indexed from, address indexed to, uint256 amount);
    
    error InsufficientBalance(uint256 available, uint256 required);
    
    modifier onlyPositive(uint256 amount) {
        require(amount > 0, "Amount must be positive");
        _;
    }
    
    /// @notice Transfer tokens to another address
    /// @param to Recipient address
    /// @param amount Amount to transfer
    function transfer(address to, uint256 amount) 
        external 
        onlyPositive(amount) 
    {
        if (balances[msg.sender] < amount) {
            revert InsufficientBalance(balances[msg.sender], amount);
        }
        balances[msg.sender] -= amount;
        balances[to] += amount;
        emit Transfer(msg.sender, to, amount);
    }
}

Extracted symbols:

  • Token (contract, public)
  • Token.transfer (function, external)
  • Token.onlyPositive (modifier)
  • Transfer (event)
  • InsufficientBalance (error)

Extracted edges:

  • Token.transfer -> Token.onlyPositive (calls) — modifier applications emit calls edges, so ctx query callers/impact and v1.edges answer access-control questions; constructor base-contract invocations are covered too.
  • Qualified library calls (Lib.fn()) resolve to the library function instead of remaining unresolved in the call graph.

NatSpec Extraction

NatSpec comments (/// and /** */) are extracted:

  • @title or @notice -> brief
  • Full content -> docstring

YAML

YAML files are tracked but not parsed for symbols (YAML doesn't have functions/classes).

What's indexed:

  • File path
  • File hash (for change detection)
  • Language type

Use cases:

  • Include config files in ctx query files
  • Track changes to CI/CD configs
  • Include in context generation for reference

Edge Types Summary

Edge TypeLanguagesDescription
callsAllFunction/method calls
extendsTS, JS, PythonClass inheritance
implementsTS, RustInterface/trait implementation
importsRust, TS/JS, Python, Go, Zig, C/C++Module imports or includes

Limitations

Cross-File Resolution

Call targets are resolved by name within the same codebase. External calls (to libraries) show as unresolved:

ctx query deps myFunction
Dependencies of 'myFunction':
------------------------------------------------------------
  calls internalHelper (line 12)      # Resolved
  calls externalLib (line 15)         # Unresolved (external)

Registering a language server with backend = "hybrid" narrows this limitation: after tree-sitter extraction, references that stayed unresolved are resolved via the server's textDocument/definition (see Hybrid mode for built-in languages).

Dynamic Calls

Dynamic or computed calls cannot be tracked:

// Static call - tracked
processData(input);

// Dynamic call - not tracked
const fn = getHandler(type);
fn(input);

// Not tracked
obj[methodName]();

Macros (Rust)

Macro-generated code is not analyzed:

// The generated impl is not indexed
#[derive(Debug, Clone)]
struct MyStruct { ... }

// Macro invocations are not followed
println!("Hello");  // Not tracked as a call

Decorators (Python)

Decorated functions are tracked, but decorator calls are not:

@cache  # Not tracked as a call
def expensive_operation():
    pass

Type Inference

We don't perform type inference, so method calls on inferred types may not resolve:

// Resolved (explicit type)
const user: User = getUser();
user.validate();  // Knows validate is on User

// May not resolve (inferred type)
const user = getUser();
user.validate();  // We don't know user is User

Indirect Calls

Calls through variables, closures, or arbitrary dynamic dispatch are not tracked as calls. Rust free-function items passed directly as callback values are recorded separately as uses when exactly one Rust function matches:

// Dynamic invocation through a variable is not tracked
let callback = process_data;
callback(input);

// A direct Rust function item is a `uses` edge, not a call
[1, 2, 3].into_iter().map(transform);

Adding Language Support

The fastest way to add a language is to register its language server in .ctx/config.toml — no code involved; see Add a language via LSP.

To add built-in tree-sitter support for a new language:

  1. Add the tree-sitter crate to Cargo.toml:

    tree-sitter-newlang = "0.x"
  2. Create a parser module in src/parser/:

    // src/parser/newlang.rs
    pub struct NewLangParser { ... }
    impl NewLangParser {
        pub fn parse(&mut self, file_path: &str, source: &str) -> Option<ParseResult>;
    }
  3. Define tree-sitter queries for symbol extraction

  4. Add the language to the Language enum in src/parser/mod.rs

  5. Update is_supported() in CodeParser

See src/parser/rust.rs or src/parser/typescript.rs for examples.

Language Detection

ctx detects language by file extension:

match extension {
    "rs" => Rust,
    "ts" => TypeScript,
    "tsx" => Tsx,
    "js" | "mjs" | "cjs" => JavaScript,
    "jsx" => Jsx,
    "py" | "pyi" => Python,
    "sol" => Solidity,
    "yaml" | "yml" => Yaml,
    "go" => Go,
    "c" | "h" => C, // `.h` is refined by parsing with C and C++
    "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" | "ipp" | "tpp" => Cpp,
    "zig" => Zig,
    _ => Unknown,
}

Unknown extensions are skipped during indexing but included in context generation — unless an [lsp.<language>] block in .ctx/config.toml claims them, in which case they are indexed through the registered language server.