mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-27 00:36:39 +00:00
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>
142 lines
4.0 KiB
Python
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
|