mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-13 15:40:05 +00:00
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:
1 parent
2cb9743731
commit
1edda3a704
2 files changed
+124
No files matched your search
@@ -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":
|
||||
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user