From 1edda3a704910fe36044b0c9c7fc6becbbba37e8 Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Fri, 25 Sep 2026 23:48:26 +0530 Subject: [PATCH] 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 63572f0cbeed3d4fd800a7d5a5460abf38a8ee85) --- graphify/extractors/ocaml.py | 67 ++++++++++++++++++++++++++++++++++++ tests/test_ocaml.py | 57 ++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/graphify/extractors/ocaml.py b/graphify/extractors/ocaml.py index ce735c13c..43e821401 100644 --- a/graphify/extractors/ocaml.py +++ b/graphify/extractors/ocaml.py @@ -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": diff --git a/tests/test_ocaml.py b/tests/test_ocaml.py index 275951e6d..090c43f49 100644 --- a/tests/test_ocaml.py +++ b/tests/test_ocaml.py @@ -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"]