Files
graphify/tests/test_ocaml.py
T
safishamsiandClaude Opus 4.8 0302bfa7af feat(ocaml): add OCaml .ml/.mli extractor (optional tree-sitter-ocaml extra)
New graphify/extractors/ocaml.py handles both the implementation grammar
(language_ocaml, .ml) and the interface grammar (language_ocaml_interface,
.mli). Emits nodes for modules, top-level/module-level values and functions,
types and their variant constructors; edges for defines/contains, open ->
imports_from, and application -> calls. Qualified paths (Geo.area) resolve to
the final value name, not the module qualifier; local let ... in bindings do
not mint nodes or steal call attribution. Cross-file open/call targets are
sourceless stubs so the corpus rewire collapses them onto the unique real
definition (no #1402 sourced-stub leak).

Wired into detect.py (CODE_EXTENSIONS), extract.py (dispatch +
_EXTRA_FOR_EXTENSION), pyproject.toml ([ocaml] extra + all + dev dep), and
README. Adds tests/test_ocaml.py (behind importorskip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-14 14:17:07 +01:00

142 lines
4.0 KiB
Python

"""Tests for the OCaml extractor (graphify/extractors/ocaml.py)."""
from __future__ import annotations
from pathlib import Path
import pytest
pytest.importorskip("tree_sitter_ocaml")
from graphify.extract import extract_ocaml
def _write(tmp_path: Path, name: str, body: str) -> Path:
p = tmp_path / name
p.write_text(body, encoding="utf-8")
return p
def _labels(r) -> set[str]:
return {n["label"] for n in r["nodes"]}
def _rel_pairs(r, relation: str) -> set[tuple[str, str]]:
lab = {n["id"]: n["label"] for n in r["nodes"]}
return {
(lab.get(e["source"], e["source"]), lab.get(e["target"], e["target"]))
for e in r["edges"]
if e["relation"] == relation
}
IMPL = """\
open Stdlib
module Shapes = struct
type shape = Circle | Square | Triangle
let pi = 3.14159
let area_of radius =
let squared = radius *. radius in
pi *. squared
let describe r =
let a = area_of r in
print_float a
end
let main () =
let a = Shapes.area_of 2.0 in
print_float a
"""
def test_impl_defines_module_values_and_types(tmp_path):
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
assert "error" not in r
labels = _labels(r)
# module, values/functions, type, variant constructors
assert {"Shapes", "pi", "area_of", "describe", "main", "shape"} <= labels
assert {"Circle", "Square", "Triangle"} <= labels
def test_impl_containment_and_defines(tmp_path):
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
# file defines the top-level module and top-level `main`
defines = _rel_pairs(r, "defines")
assert ("shapes.ml", "Shapes") in defines
assert ("shapes.ml", "main") in defines
# module contains its members
contains = _rel_pairs(r, "contains")
assert ("Shapes", "area_of") in contains
assert ("Shapes", "shape") in contains
# variant constructors are contained by their type
assert ("shape", "Circle") in contains
def test_impl_calls_resolve_same_file(tmp_path):
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
calls = _rel_pairs(r, "calls")
# describe -> area_of is a same-file, unambiguous resolution
assert ("describe", "area_of") in calls
# a qualified call `Shapes.area_of` resolves to the value `area_of`, NOT the
# module qualifier `Shapes`.
assert ("main", "area_of") in calls
assert ("main", "Shapes") not in calls
def test_impl_open_emits_import(tmp_path):
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
imports = _rel_pairs(r, "imports_from")
assert ("shapes.ml", "Stdlib") in imports
def test_open_stub_is_sourceless(tmp_path):
# An `open`ed external module must be a SOURCELESS stub so the corpus rewire
# can collapse/prune it without baking this file's path into the id (#1402).
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
stubs = [n for n in r["nodes"] if n["label"] == "Stdlib"]
assert stubs and all(n["source_file"] == "" for n in stubs)
# origin_file is an internal rewire hint, never a real source path.
assert all(n.get("source_location") == "" for n in stubs)
INTERFACE = """\
open Base
module type Store = sig
type t
val make : int -> t
val size : t -> int
end
type color = Red | Green | Blue
val hello : string -> unit
"""
def test_interface_defines_signatures(tmp_path):
r = extract_ocaml(_write(tmp_path, "store.mli", INTERFACE))
assert "error" not in r
labels = _labels(r)
assert {"Store", "make", "size", "color", "hello"} <= labels
assert {"Red", "Green", "Blue"} <= labels
# interfaces have no expression bodies -> no calls
assert not [e for e in r["edges"] if e["relation"] == "calls"]
def test_no_dangling_edges(tmp_path):
r = extract_ocaml(_write(tmp_path, "shapes.ml", IMPL))
ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in ids, e
assert e["target"] in ids, e
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