Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ body:
- C
- C++
- Ruby
- Kotlin
- Not language-specific
validations:
required: false
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ build/
# Virtual environments
.venv/
venv/
uv.lock

# Testing
.pytest_cache/
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ All `file_path` arguments are **relative to the repo root** (e.g., `"src/main.py
| `.c`, `.h` | CPlugin | `function_definition`, `struct_specifier`, `type_definition` |
| `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hh` | CppPlugin | `class_specifier`, `function_definition`, `struct_specifier`, `namespace_definition` |
| `.rb` | RubyPlugin | `class`, `module`, `method`, `singleton_method` |
| `.kt` | KotlinPlugin | `class_declaration`, `object_declaration`, `function_declaration` |

## Commands

Expand Down Expand Up @@ -120,6 +121,7 @@ MCP tool call → server.py → indexer.py → FileEntry.plugin → tree-sitter
| `c.py` | CPlugin |
| `cpp.py` | CppPlugin (inherits CPlugin) |
| `ruby.py` | RubyPlugin |
| `kotlin.py` | KotlinPlugin |
| `_template.py` | Boilerplate for adding new languages |

Each plugin implements:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

**Stop feeding entire files to your AI agent.**

codetree is an [MCP](https://modelcontextprotocol.io/) server that gives coding agents structured code understanding via [tree-sitter](https://tree-sitter.github.io/) — so they ask precise questions instead of reading thousands of lines. 23 tools, 10 languages, ~1 second startup. No vector DB, no embedding model, no config.
codetree is an [MCP](https://modelcontextprotocol.io/) server that gives coding agents structured code understanding via [tree-sitter](https://tree-sitter.github.io/) — so they ask precise questions instead of reading thousands of lines. 23 tools, 11 languages, ~1 second startup. No vector DB, no embedding model, no config.

## Quick Start

Expand Down Expand Up @@ -152,6 +152,7 @@ The agent sees every class, method, and docstring — with line numbers — with
| C | `.c`, `.h` |
| C++ | `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hh` |
| Ruby | `.rb` |
| Kotlin | `.kt` |

## Editor Setup

Expand Down
9 changes: 5 additions & 4 deletions docs/LANDING_PAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ cls Calculator:4 # A scientific calculator.

## 6. Supported Languages

10 languages. 16 file extensions. All backed by official tree-sitter grammars. Plus a persistent graph layer for onboarding, change impact, and security analysis.
11 languages. 17 file extensions. All backed by official tree-sitter grammars. Plus a persistent graph layer for onboarding, change impact, and security analysis.

| Language | Extensions |
|----------|-----------|
Expand All @@ -204,6 +204,7 @@ cls Calculator:4 # A scientific calculator.
| C | `.c` `.h` |
| C++ | `.cpp` `.cc` `.cxx` `.hpp` `.hh` |
| Ruby | `.rb` |
| Kotlin | `.kt` |

Adding a new language is mechanical: copy a template file, implement 5 methods, register in one file, done.

Expand Down Expand Up @@ -308,7 +309,7 @@ claude mcp add codetree -- uvx --from mcp-server-codetree codetree --root .
┌─────────────────────────────────────────────────┐
│ Language Plugins (one per language) │
│ │
│ Python │ JS │ TS │ Go │ Rust │ Java │ C │ C++ │ Ruby │
│ Python │ JS │ TS │ Go │ Rust │ Java │ C │ C++ │ Ruby │ Kotlin │
│ │
│ Each implements: │
│ extract_skeleton() │
Expand All @@ -335,8 +336,8 @@ claude mcp add codetree -- uvx --from mcp-server-codetree codetree --root .
| Metric | Value |
|--------|-------|
| MCP tools | 23 |
| Supported languages | 10 |
| File extensions | 16 |
| Supported languages | 11 |
| File extensions | 17 |
| Test count | 999 |
| Startup time | ~1 second |
| Install | `uvx --from mcp-server-codetree codetree` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies = [
"tree-sitter-c>=0.23.0",
"tree-sitter-cpp>=0.23.0",
"tree-sitter-ruby>=0.23.0",
"tree-sitter-kotlin>=0.23.0",
"fastmcp>=2.0.0",
]

Expand Down
272 changes: 272 additions & 0 deletions src/codetree/languages/kotlin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
from tree_sitter import Language, Parser, Query
import tree_sitter_kotlin as tskotlin
from .base import LanguagePlugin, _matches, _fill_docs_from_siblings

_LANGUAGE = Language(tskotlin.language())
_PARSER = Parser(_LANGUAGE)


def _parse(source: bytes):
return _PARSER.parse(source)


class KotlinPlugin(LanguagePlugin):
extensions = (".kt", ".kts")

def extract_skeleton(self, source: bytes) -> list[dict]:
tree = _parse(source)
results = []

# Top-level classes / interfaces (both use class_declaration)
q = Query(_LANGUAGE, "(source_file (class_declaration (identifier) @name) @def)")
for _, m in _matches(q, tree.root_node):
node_def = m["def"]
# Check if it's an interface
sym_type = "class"
for child in node_def.children:
if child.type == "interface":
sym_type = "interface"
break

results.append({
"type": sym_type,
"name": m["name"].text.decode("utf-8", errors="replace"),
"line": m["name"].start_point[0] + 1,
"parent": None,
"params": "",
})

# Top-level objects
q = Query(_LANGUAGE, "(source_file (object_declaration (identifier) @name) @def)")
for _, m in _matches(q, tree.root_node):
results.append({
"type": "class",
"name": m["name"].text.decode("utf-8", errors="replace"),
"line": m["name"].start_point[0] + 1,
"parent": None,
"params": "",
})

# Top-level functions
q = Query(_LANGUAGE, "(source_file (function_declaration (identifier) @name (function_value_parameters) @params) @def)")
for _, m in _matches(q, tree.root_node):
results.append({
"type": "function",
"name": m["name"].text.decode("utf-8", errors="replace"),
"line": m["name"].start_point[0] + 1,
"parent": None,
"params": m["params"].text.decode("utf-8", errors="replace"),
})

# Methods inside classes/interfaces
q = Query(_LANGUAGE, """
(class_declaration
(identifier) @class_name
(class_body
(function_declaration
(identifier) @method_name
(function_value_parameters) @params)))
""")
for _, m in _matches(q, tree.root_node):
results.append({
"type": "method",
"name": m["method_name"].text.decode("utf-8", errors="replace"),
"line": m["method_name"].start_point[0] + 1,
"parent": m["class_name"].text.decode("utf-8", errors="replace"),
"params": m["params"].text.decode("utf-8", errors="replace"),
})

# Methods inside objects
q = Query(_LANGUAGE, """
(object_declaration
(identifier) @class_name
(class_body
(function_declaration
(identifier) @method_name
(function_value_parameters) @params)))
""")
for _, m in _matches(q, tree.root_node):
results.append({
"type": "method",
"name": m["method_name"].text.decode("utf-8", errors="replace"),
"line": m["method_name"].start_point[0] + 1,
"parent": m["class_name"].text.decode("utf-8", errors="replace"),
"params": m["params"].text.decode("utf-8", errors="replace"),
})

# Fill doc fields from preceding comments
for item in results:
item.setdefault("doc", "")
_fill_docs_from_siblings(results, tree.root_node, _LANGUAGE, [
"(class_declaration (identifier) @name) @def",
"(object_declaration (identifier) @name) @def",
"(function_declaration (identifier) @name) @def",
])

results.sort(key=lambda x: x["line"])
return results

def extract_symbol_source(self, source: bytes, name: str) -> tuple[str, int] | None:
tree = _parse(source)

# Classes, objects
for q_str in [
"(class_declaration (identifier) @name) @def",
"(object_declaration (identifier) @name) @def",
]:
for _, m in _matches(Query(_LANGUAGE, q_str), tree.root_node):
if m["name"].text.decode("utf-8", errors="replace") == name:
node = m["def"]
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace"), node.start_point[0] + 1

# Functions/Methods
q = Query(_LANGUAGE, "(function_declaration (identifier) @name) @def")
for _, m in _matches(q, tree.root_node):
if m["name"].text.decode("utf-8", errors="replace") == name:
node = m["def"]
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace"), node.start_point[0] + 1

return None

def extract_calls_in_function(self, source: bytes, fn_name: str) -> list[str]:
tree = _parse(source)
fn_node = None
q = Query(_LANGUAGE, "(function_declaration (identifier) @name) @def")
for _, m in _matches(q, tree.root_node):
if m["name"].text.decode("utf-8", errors="replace") == fn_name:
fn_node = m["def"]
break
if fn_node is None:
return []

calls = set()
# Method calls: foo(), foo.bar()
q = Query(_LANGUAGE, """
(call_expression
[
(identifier) @called
(navigation_expression (identifier) @called)
])
""")
for _, m in _matches(q, fn_node):
node = m["called"]
if node.parent and node.parent.type == "navigation_expression":
ids = [c for c in node.parent.children if c.type == "identifier"]
if ids and node == ids[-1]:
calls.add(node.text.decode("utf-8", errors="replace"))
else:
calls.add(node.text.decode("utf-8", errors="replace"))

return sorted(calls)

def extract_symbol_usages(self, source: bytes, name: str) -> list[dict]:
tree = _parse(source)
usages = []
seen = set()
# Kotlin uses identifier for most things
q = Query(_LANGUAGE, f'((identifier) @name (#eq? @name "{name}"))')
for _, m in _matches(q, tree.root_node):
node = m["name"]
key = (node.start_point[0], node.start_point[1])
if key not in seen:
seen.add(key)
usages.append({"line": node.start_point[0] + 1, "col": node.start_point[1]})

usages.sort(key=lambda x: (x["line"], x["col"]))
return usages

def extract_imports(self, source: bytes) -> list[dict]:
tree = _parse(source)
results = []
q = Query(_LANGUAGE, "(import) @imp")
for _, m in _matches(q, tree.root_node):
node = m["imp"]
results.append({
"line": node.start_point[0] + 1,
"text": node.text.decode("utf-8", errors="replace").strip(),
})
results.sort(key=lambda x: x["line"])
return results

def compute_complexity(self, source: bytes, fn_name: str) -> dict | None:
tree = _parse(source)
fn_node = None
q = Query(_LANGUAGE, "(function_declaration (identifier) @name) @def")
for _, m in _matches(q, tree.root_node):
if m["name"].text.decode("utf-8", errors="replace") == fn_name:
fn_node = m["def"]
break
if fn_node is None:
return None

branch_map = {
"if_expression": "if",
"for_statement": "for",
"while_statement": "while",
"do_while_statement": "do_while",
"catch_block": "catch",
"when_expression": "when",
"when_entry": "case",
}
counts: dict[str, int] = {}

def walk(node):
if node.type in branch_map:
label = branch_map[node.type]
counts[label] = counts.get(label, 0) + 1
elif node.type in ("&&", "||"):
counts[node.type] = counts.get(node.type, 0) + 1
for child in node.children:
walk(child)

walk(fn_node)
total = 1 + sum(counts.values())
return {"total": total, "breakdown": counts}

def extract_variables(self, source: bytes, fn_name: str) -> list[dict]:
tree = _parse(source)
fn_node = None
q = Query(_LANGUAGE, "(function_declaration (identifier) @name) @def")
for _, m in _matches(q, tree.root_node):
if m["name"].text.decode("utf-8", errors="replace") == fn_name:
fn_node = m["def"]
break
if fn_node is None:
return []

results = []
seen = set()

def _add(name, line, var_type="", kind="local"):
if name not in seen:
seen.add(name)
results.append({"name": name, "line": line, "type": var_type, "kind": kind})

# Parameters
q_params = Query(_LANGUAGE, "(parameter (identifier) @name (user_type)? @type)")
for _, m in _matches(q_params, fn_node):
type_text = m.get("type").text.decode("utf-8", errors="replace") if m.get("type") else ""
_add(m["name"].text.decode("utf-8", errors="replace"),
m["name"].start_point[0] + 1,
var_type=type_text,
kind="parameter")

# Local variables (val/var)
q_vars = Query(_LANGUAGE, "(variable_declaration (identifier) @name (user_type)? @type)")
for _, m in _matches(q_vars, fn_node):
type_text = m.get("type").text.decode("utf-8", errors="replace") if m.get("type") else ""
_add(m["name"].text.decode("utf-8", errors="replace"),
m["name"].start_point[0] + 1,
var_type=type_text,
kind="local")

return results

def check_syntax(self, source: bytes) -> bool:
return _parse(source).root_node.has_error

def _get_parser(self):
return _PARSER

def _get_language(self):
return _LANGUAGE
3 changes: 3 additions & 0 deletions src/codetree/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .languages.c import CPlugin
from .languages.cpp import CppPlugin
from .languages.ruby import RubyPlugin
from .languages.kotlin import KotlinPlugin

# All supported file extensions mapped to plugin instances.
# To add a new language: import its plugin and add its extensions here.
Expand All @@ -29,6 +30,8 @@
".hpp": CppPlugin(),
".hh": CppPlugin(),
".rb": RubyPlugin(),
".kt": KotlinPlugin(),
".kts": KotlinPlugin(),
}


Expand Down
Loading