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
| Language | Extensions | Symbol Extraction | Edge Tracking | Status |
|---|---|---|---|---|
| Rust | .rs | Full | Calls, Implements, Imports | Full |
| TypeScript | .ts | Full | Calls, Extends, Implements, Imports | Full |
| TSX | .tsx | Full | Calls, Extends, Implements, Imports | Full |
| JavaScript | .js, .mjs, .cjs | Full | Calls, Extends, Imports | Full |
| JSX | .jsx | Full | Calls, Extends, Imports | Full |
| Python | .py, .pyi | Full | Calls, Extends, Imports | Full |
| Go | .go | Full | Calls, Imports | Full |
| C | .c, .h when selected as C | Full | Calls, Includes | Full |
| C++ | .cc, .cpp, .cxx, .hh, .hpp, .hxx, .ipp, .tpp, .h when selected as C++ | Full | Calls, Includes | Full |
| Zig | .zig | Full | Calls, Imports | Full |
| Solidity | .sol | Full | Calls | Full |
| YAML | .yaml, .yml | File tracking only | N/A | Partial |
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
| Kind | Example | Notes |
|---|---|---|
| Function | fn main() | Top-level functions |
| Method | fn new(&self) | Functions in impl blocks |
| Struct | struct User | Struct definitions |
| Enum | enum Status | Enum definitions |
| Trait | trait Display | Trait definitions |
| Type alias | type Result<T> = ... | Type aliases |
| Const | const MAX: i32 | Constants |
| Static | static GLOBAL: &str | Static variables |
Visibility Detection
pub-> publicpub(crate)-> cratepub(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::newcallsgenerate_idUser::newcallsto_stringUser::verifycallsvalidateUserimplementsAuthenticate
Documentation Extraction
Rust doc comments (/// and //!) are extracted:
- First line becomes the
brieffield - Full content becomes
docstring
TypeScript
Extracted Symbols
| Kind | Example | Notes |
|---|---|---|
| Function | function foo() | Named functions |
| Arrow Function | const foo = () => {} | With const declaration |
| Class | class User | Class declarations |
| Method | authenticate() | Class methods |
| Interface | interface IUser | Interface declarations |
| Type Alias | type UserId = string | Type definitions |
| Enum | enum Status | Enum declarations |
Visibility Detection
export-> public- No
export-> private privatekeyword in class -> privatepublickeyword in class -> publicprotectedkeyword 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:
DefaultUserServiceimplementsUserServiceauthenticatecallsdecodeTokenauthenticatecallsfindUserdecodeTokencallsjwt.decode
JSDoc Extraction
JSDoc comments (/** */) are extracted:
@descriptionor 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
| Kind | Example | Notes |
|---|---|---|
| Function | def foo(): | Top-level functions |
| Async Function | async def foo(): | Async functions |
| Class | class User: | Class definitions |
| Method | def validate(self): | Instance methods |
| Static Method | @staticmethod def create(): | Static methods |
| Class Method | @classmethod def from_dict(cls): | Class methods |
| Constant | MAX_RETRIES = 3 | UPPER_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:
AdminextendsUserAdmin.__init__callssuper().__init__User.from_dictcallsUserfetch_usercallsdb.getfetch_usercallsUser.from_dict
Docstring Extraction
Python docstrings (triple-quoted strings) are extracted:
- First line ->
brief - Full content ->
docstring
Go
Extracted Symbols
| Kind | Example | Notes |
|---|---|---|
| Function | func Handle() | Top-level functions |
| Method | func (s *Server) Start() | Methods with receivers |
| Struct | type User struct {} | Struct type definitions |
| Interface | type Reader interface {} | Interface type definitions |
| Const | const MaxRetries = 3 | Constants |
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:
NewUsercallsgenerateID
Documentation Extraction
Go doc comments (// immediately preceding a declaration) are extracted:
- First line becomes the
brieffield - 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
| Kind | Example | Notes |
|---|---|---|
| Function | fn run() void | Named top-level functions |
| Method | fn start(self: *Server) void | Functions in named containers |
| Struct | const Server = struct {} | Named struct initializers |
| Enum | const Mode = enum { fast } | Named enum initializers |
| Type | const Value = union(enum) { ... } | Union, opaque, and error-set initializers |
| Const / Variable | const limit = 10, var count = 0 | Container-level declarations |
| Named test | test "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
| Kind | Example | Notes |
|---|---|---|
| Contract | contract Token | Contract definitions |
| Function | function transfer() | Contract functions |
| Modifier | modifier onlyOwner | Function modifiers |
| Event | event Transfer | Event definitions |
| Struct | struct Proposal | Struct definitions |
| Enum | enum Status | Enum definitions |
| Error | error Unauthorized | Custom errors |
Visibility Detection
public-> publicexternal-> publicinternal-> 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 emitcallsedges, soctx query callers/impactandv1.edgesanswer 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:
@titleor@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 Type | Languages | Description |
|---|---|---|
calls | All | Function/method calls |
extends | TS, JS, Python | Class inheritance |
implements | TS, Rust | Interface/trait implementation |
imports | Rust, 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 callDecorators (Python)
Decorated functions are tracked, but decorator calls are not:
@cache # Not tracked as a call
def expensive_operation():
passType 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 UserIndirect 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:
-
Add the tree-sitter crate to
Cargo.toml:tree-sitter-newlang = "0.x" -
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>; } -
Define tree-sitter queries for symbol extraction
-
Add the language to the
Languageenum insrc/parser/mod.rs -
Update
is_supported()inCodeParser
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.