fix(ocaml): extract classes and their methods and instance variables

The OCaml walker had no branch for class_definition, so a class object --
its methods, its instance variables, and the calls made from method bodies --
was dropped entirely. A file made only of classes produced no nodes at all.

Add a class_definition branch that mints the class node (defines/contains),
walks the object body (object_expression in .ml, class_body_type in .mli) for
method and instance-variable members, and attributes calls in each method body
to that method. Method names are deliberately not registered as local defs:
they are reachable only through obj#m, so binding a bare call to a same-named
method would be a false edge.

(cherry picked from commit 63572f0cbe)
This commit is contained in:
rajatnagda45 authored and safishamsi committed 2026-09-26 13:45:58 +01:00
1 parent 2cb9743731
commit 1edda3a704
2 files changed
+124

No files matched your search

+67
View File
@@ -101,6 +101,19 @@ def extract_ocaml(path: Path) -> dict:
def line_of(node) -> int:
return node.start_point[0] + 1
def _find_object_body(binding):
"""Locate the `object ... end` body of a class binding. It is the
`object_expression` in an implementation and the `class_body_type` in an
interface; a parametric class (`class c x = object ... end`) nests it a
level deeper, so search descendants rather than direct children."""
stack = list(binding.children)
while stack:
n = stack.pop()
if n.type in ("object_expression", "class_body_type"):
return n
stack.extend(n.children)
return None
def named_child_text(node, child_type: str) -> str | None:
for child in node.children:
if child.type == child_type:
@@ -251,6 +264,60 @@ def extract_ocaml(path: Path) -> dict:
emit_type(binding, container_nid)
return
if t == "class_definition":
# OCaml classes: `class c = object ... end` (.ml) and class-type
# signatures `class c : object ... end` (.mli). Without this branch
# the whole class -- its methods and instance variables -- was
# dropped, so a module made only of classes yielded no nodes at all.
# tree-sitter nests the name and the `object` body under a
# class_binding; the body is an `object_expression` in .ml and a
# `class_body_type` in .mli.
binding = next((c for c in node.children
if c.type == "class_binding"), None)
if binding is not None:
cname = named_child_text(binding, "class_name")
if cname:
line = line_of(node)
cnid = _make_id(stem, cname)
add_node(cnid, cname, line)
add_edge(container_nid, cnid,
"defines" if container_nid == file_nid else "contains", line)
register_def(cname, cnid)
body = _find_object_body(binding)
if body is not None:
for member in body.children:
if member.type in ("method_definition", "method_specification"):
mname = named_child_text(member, "method_name")
if not mname:
continue
mline = line_of(member)
mnid = _make_id(stem, cname, mname)
add_node(mnid, mname, mline)
# A method is a callable class member, so use the
# "method" relation every other extractor emits
# (an instance `val` stays "contains" - it's a
# field). This keeps OCaml methods first-class in
# the call-flow view and caller/callee index,
# which key on "method" not "contains".
add_edge(cnid, mnid, "method", mline)
# Attribute calls in the method body to the
# method. Methods are NOT registered as local
# defs: they are only reachable through `obj#m`,
# never as a bare `m ...`, so binding a bare call
# to a same-named method would be a false edge.
for child in member.children:
walk(child, cnid, mnid)
elif member.type in ("instance_variable_definition",
"instance_variable_specification"):
vname = named_child_text(member, "instance_variable_name")
if not vname:
continue
vline = line_of(member)
vnid = _make_id(stem, cname, vname)
add_node(vnid, vname, vline)
add_edge(cnid, vnid, "contains", vline)
return
if t == "application_expression":
fn = node.named_children[0] if node.named_children else None
if fn is not None and fn.type == "value_path":
+57
View File
@@ -177,3 +177,60 @@ def test_missing_file_returns_error(tmp_path):
r = extract_ocaml(tmp_path / "nope.ml")
assert r["nodes"] == [] and r["edges"] == []
assert "error" in r
CLASSES = """\
let log s = print_string s
class counter = object
val mutable count = 0
method get = count
method incr = count <- count + 1
end
class widget = object
method render = log "rendered"
end
"""
def test_impl_extracts_classes_methods_and_instance_vars(tmp_path):
r = extract_ocaml(_write(tmp_path, "ui.ml", CLASSES))
assert "error" not in r
labels = _labels(r)
# the classes, their methods, and their instance variables are all present
assert {"counter", "get", "incr", "count", "widget", "render"} <= labels
# methods hang off their class via the "method" relation
method = _rel_pairs(r, "method")
assert {("counter", "get"), ("counter", "incr"),
("widget", "render")} <= method
# an instance `val` is a field, so it uses "contains"
contains = _rel_pairs(r, "contains")
assert ("counter", "count") in contains
def test_class_method_body_calls_are_attributed_to_the_method(tmp_path):
r = extract_ocaml(_write(tmp_path, "ui.ml", CLASSES))
# `method render = log ...` calls the top-level `log`, credited to render
assert ("render", "log") in _rel_pairs(r, "calls")
CLASS_INTERFACE = """\
class counter : object
val count : int
method get : int
method incr : unit
end
"""
def test_interface_extracts_class_signatures(tmp_path):
r = extract_ocaml(_write(tmp_path, "counter.mli", CLASS_INTERFACE))
assert "error" not in r
assert {"counter", "get", "incr", "count"} <= _labels(r)
method = _rel_pairs(r, "method")
assert {("counter", "get"), ("counter", "incr")} <= method
contains = _rel_pairs(r, "contains")
assert ("counter", "count") in contains
# a signature has no expression body -> no calls
assert not [e for e in r["edges"] if e["relation"] == "calls"]