Skip to content
Merged
4 changes: 3 additions & 1 deletion CHANGELOG.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions assets/badge-tests.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 19 additions & 17 deletions mcp_server/core/ast_extractor_registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Registry for the extra-language tree-sitter extractors.

Builds the (imports, definitions, calls) extractor callables for the JVM,
Builds the (imports, definitions) extractor callables for the JVM,
C-family, and scripting language groups and merges them into one dict for
ast_parser._EXTRACTORS. Split out so ast_parser.py stays under 300 lines.

Expand All @@ -12,7 +12,6 @@
from typing import TYPE_CHECKING, Callable

from mcp_server.core.codebase_parser import ImportInfo, SymbolDef
from mcp_server.core.ast_extractors import extract_calls_generic
from mcp_server.core.ast_extractors_clike import (
extract_c_definitions,
extract_c_imports,
Expand All @@ -37,27 +36,30 @@
from tree_sitter import Node
from tree_sitter_language_pack import SupportedLanguage

Extractor = Callable[
["Node", bytes], tuple[list[ImportInfo], list[SymbolDef], list[str]]
]
Extractor = Callable[["Node", bytes], tuple[list[ImportInfo], list[SymbolDef]]]


def _make_extractor(
imports_fn: Callable[["Node", bytes], list[ImportInfo]],
defs_fn: Callable[["Node", bytes], list[SymbolDef]],
) -> Extractor:
"""Compose an extractor from an imports fn, a defs fn, and generic calls."""

def _extract(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:

return (
imports_fn(root, source),
defs_fn(root, source),
extract_calls_generic(root, source),
)
"""Compose an extractor from an imports fn and a defs fn.

Precondition: `imports_fn`/`defs_fn` are pure (no I/O), taking the same
`(root, source)` pair.
Postcondition: returns a callable producing `(imports, definitions)` —
the flat per-file call list this used to also return was computed via
`extract_calls_generic(root, source)` and then discarded by every
caller (`parse_file_ast` only reads the `calls_per_function` map,
populated separately via `extract_calls_per_function`); the tuple
element was removed as dead code (issue #249 boy-scout pass). With
this the only production call site gone, `extract_calls_generic` had
no caller left but its own direct unit test — deleted from
`ast_extractors.py` rather than kept for a hypothetical future one.
"""

def _extract(root: Node, source: bytes) -> tuple[list[ImportInfo], list[SymbolDef]]:
return imports_fn(root, source), defs_fn(root, source)

return _extract

Expand Down
107 changes: 50 additions & 57 deletions mcp_server/core/ast_extractors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Tree-sitter AST extractors for Python and JavaScript/TypeScript.

Additional languages (Go, Swift, Rust) in ast_extractors_extra.py.
Also provides the generic call-site extractor used by all languages.

Pure functions — no I/O.
"""
Expand Down Expand Up @@ -217,25 +216,6 @@ def _extract_js_class(
_extract_js_node(child, source, defs, cls_name)


# ── Generic call extraction ──────────────────────────────────────────────


def extract_calls_generic(root: Node, source: bytes) -> list[str]:
"""Extract all function/method call names from AST."""
calls: list[str] = []
seen: set[str] = set()
for call_type in ("call", "call_expression"):
for node in _walk_type(root, call_type):
func = node.child_by_field_name("function")
if not func:
continue
name = _text(func, source).strip()
if name and name not in seen and len(name) < _MAX_CALL_NAME_LEN:
calls.append(name)
seen.add(name)
return calls


# ── Per-function call extraction (caller-qualified) ─────────────────────

# Tree-sitter node types that represent a function/method definition
Expand Down Expand Up @@ -297,41 +277,54 @@ def extract_calls_per_function(
qname means we can't attach edges to them.
"""
out: dict[str, list[str]] = {}

def walk(node: Node, class_scope: str) -> None:
for child in node.children:
ntype = child.type
if ntype in _CLASS_NODE_TYPES:
name_node = child.child_by_field_name("name")
cls = _text(name_node, source) if name_node else class_scope
body = child.child_by_field_name("body") or child
walk(body, cls or class_scope)
elif ntype == "decorated_definition":
walk(child, class_scope)
elif ntype in _FUNCTION_NODE_TYPES:
name_node = child.child_by_field_name("name")
fn_name = _text(name_node, source) if name_node else ""
body = child.child_by_field_name("body") or child
if fn_name:
qname = f"{class_scope}.{fn_name}" if class_scope else fn_name
calls: list[str] = []
seen: set[str] = set()
for call_type in _CALL_NODE_TYPES:
for call in _walk_type(body, call_type):
base = _callee_basename(call, source)
if (
base
and base not in seen
and len(base) < _MAX_CALL_NAME_LEN
):
calls.append(base)
seen.add(base)
out[qname] = calls
# Recurse into body for nested definitions (inner
# functions, closures that define named functions).
walk(body, class_scope)
else:
walk(child, class_scope)

walk(root, "")
_walk_for_calls(root, "", source, out)
return out


def _walk_for_calls(
node: Node,
class_scope: str,
source: bytes,
out: dict[str, list[str]],
) -> None:
"""Recurse through ``node``'s children, tracking the enclosing class.

Precondition: `out` is the accumulator `extract_calls_per_function`
returns; this function only adds keys, it does not read existing ones.
Postcondition: every named function/method reachable from `node` has
its qualified name mapped to its deduped callee-basename list in `out`.
"""
for child in node.children:
ntype = child.type
if ntype in _CLASS_NODE_TYPES:
name_node = child.child_by_field_name("name")
cls = _text(name_node, source) if name_node else class_scope
body = child.child_by_field_name("body") or child
_walk_for_calls(body, cls or class_scope, source, out)
elif ntype == "decorated_definition":
_walk_for_calls(child, class_scope, source, out)
elif ntype in _FUNCTION_NODE_TYPES:
name_node = child.child_by_field_name("name")
fn_name = _text(name_node, source) if name_node else ""
body = child.child_by_field_name("body") or child
if fn_name:
qname = f"{class_scope}.{fn_name}" if class_scope else fn_name
out[qname] = _collect_call_basenames(body, source)
# Recurse into body for nested definitions (inner
# functions, closures that define named functions).
_walk_for_calls(body, class_scope, source, out)
else:
_walk_for_calls(child, class_scope, source, out)


def _collect_call_basenames(body: Node, source: bytes) -> list[str]:
"""Deduped, order-preserving callee basenames for every call under `body`."""
calls: list[str] = []
seen: set[str] = set()
for call_type in _CALL_NODE_TYPES:
for call in _walk_type(body, call_type):
base = _callee_basename(call, source)
if base and base not in seen and len(base) < _MAX_CALL_NAME_LEN:
calls.append(base)
seen.add(base)
return calls
49 changes: 16 additions & 33 deletions mcp_server/core/ast_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from mcp_server.core.codebase_parser import parse_file
from mcp_server.core.ast_extractors import (
extract_calls_per_function,
extract_calls_generic,
extract_python_definitions,
extract_python_imports,
extract_js_definitions,
Expand Down Expand Up @@ -101,7 +100,7 @@ def parse_file_ast(path: str, content: bytes) -> FileAnalysis:
return parse_file(path, text)

extractor, tree = result
imports, definitions, calls = extractor(tree.root_node, content)
imports, definitions = extractor(tree.root_node, content)
docstring = _extract_module_doc(tree.root_node, language, content)
# Caller-qualified call map — works across every language the
# extractor covers because it targets tree-sitter node types shared
Expand Down Expand Up @@ -156,13 +155,12 @@ def _extract_module_doc(
def _extract_python(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:
"""Extract Python imports, definitions, and call sites."""
) -> tuple[list[ImportInfo], list[SymbolDef]]:
"""Extract Python imports and definitions."""

imports = extract_python_imports(root, source)
definitions = extract_python_definitions(root, source)
calls = extract_calls_generic(root, source)
return imports, definitions, calls
return imports, definitions


# ── JS/TS extractor ──────────────────────────────────────────────────────
Expand All @@ -171,13 +169,10 @@ def _extract_python(
def _extract_js(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:
"""Extract JavaScript/TypeScript imports, definitions, and calls."""
) -> tuple[list[ImportInfo], list[SymbolDef]]:
"""Extract JavaScript/TypeScript imports and definitions."""

imports = extract_js_imports(root, source)
definitions = extract_js_definitions(root, source)
calls = extract_calls_generic(root, source)
return imports, definitions, calls
return extract_js_imports(root, source), extract_js_definitions(root, source)


# ── Go extractor ─────────────────────────────────────────────────────────
Expand All @@ -186,40 +181,28 @@ def _extract_js(
def _extract_go(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:
"""Extract Go imports, definitions, and calls."""
) -> tuple[list[ImportInfo], list[SymbolDef]]:
"""Extract Go imports and definitions."""

return (
extract_go_imports(root, source),
extract_go_definitions(root, source),
extract_calls_generic(root, source),
)
return extract_go_imports(root, source), extract_go_definitions(root, source)


def _extract_swift(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:
"""Extract Swift imports, definitions, and calls."""
) -> tuple[list[ImportInfo], list[SymbolDef]]:
"""Extract Swift imports and definitions."""

return (
extract_swift_imports(root, source),
extract_swift_definitions(root, source),
extract_calls_generic(root, source),
)
return extract_swift_imports(root, source), extract_swift_definitions(root, source)


def _extract_rust(
root: Node,
source: bytes,
) -> tuple[list[ImportInfo], list[SymbolDef], list[str]]:
"""Extract Rust imports, definitions, and calls."""
) -> tuple[list[ImportInfo], list[SymbolDef]]:
"""Extract Rust imports and definitions."""

return (
extract_rust_imports(root, source),
extract_rust_definitions(root, source),
extract_calls_generic(root, source),
)
return extract_rust_imports(root, source), extract_rust_definitions(root, source)


# Keyed by the language pack's own `SupportedLanguage` literal, not by `str`:
Expand Down
36 changes: 0 additions & 36 deletions tests_py/core/test_ast_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,42 +119,6 @@ def test_interface_and_type(self) -> None:
assert r.language == "typescript"


class TestCallExtraction:
def test_python_calls(self) -> None:
code = b"""
result = process(data)
x = helper(1, 2)
obj.method()
"""
if _HAS_TREE_SITTER:
from mcp_server.core.ast_extractors import extract_calls_generic
from tree_sitter_language_pack import get_parser

tree = get_parser("python").parse(code)
calls = extract_calls_generic(tree.root_node, code)
assert "process" in calls
assert "helper" in calls
else:
r = parse_file_ast("test.py", code)
assert r.language == "python"

def test_js_calls(self) -> None:
code = b"""
const x = fetchData(url);
console.log("hello");
"""
if _HAS_TREE_SITTER:
from mcp_server.core.ast_extractors import extract_calls_generic
from tree_sitter_language_pack import get_parser

tree = get_parser("javascript").parse(code)
calls = extract_calls_generic(tree.root_node, code)
assert any("fetchData" in c for c in calls)
else:
r = parse_file_ast("app.js", code)
assert r.language == "javascript"


class TestGoExtractors:
def test_go_struct_and_method(self) -> None:
code = b"""
Expand Down
Loading