diff --git a/graphify/extractors/julia.py b/graphify/extractors/julia.py index 93aea146e..504efd2ad 100644 --- a/graphify/extractors/julia.py +++ b/graphify/extractors/julia.py @@ -214,6 +214,60 @@ def extract_julia(path: Path) -> dict: function_bodies.append((func_nid, node)) return + # Macro: macro foo(...) ... end. Same grammar shape as a function (a + # `signature` wrapping a call_expression). Without this branch every + # macro definition was dropped, even though macros are first-class, + # heavily used definitions in Julia. + if t == "macro_definition": + sig_node = next((c for c in node.children if c.type == "signature"), None) + if sig_node: + macro_name = _func_name_from_signature(sig_node) + if macro_name: + macro_nid = _make_id(stem, "@" + macro_name) + line = node.start_point[0] + 1 + add_node(macro_nid, f"@{macro_name}", line) + add_edge(scope_nid, macro_nid, "defines", line) + function_bodies.append((macro_nid, node)) + return + + # @enum: `@enum Name a b c` or `@enum Name begin a; b end`. This lowers + # to a macrocall_expression; the first identifier in the argument list is + # the enum type, the rest (including those inside a begin/end block) are + # its members. Other macro calls fall through so definitions nested in + # e.g. an `@testset begin ... end` are still walked. + if t == "macrocall_expression": + macro_id = next((c for c in node.children if c.type == "macro_identifier"), None) + macro_name = None + if macro_id is not None: + id_node = next((c for c in macro_id.children if c.type == "identifier"), None) + if id_node is not None: + macro_name = _read_text(id_node, source) + if macro_name == "enum": + arglist = next((c for c in node.children if c.type == "macro_argument_list"), None) + names = [] + if arglist is not None: + for c in arglist.children: + if c.type == "identifier": + names.append(c) + elif c.type == "compound_statement": + names.extend(cc for cc in c.children if cc.type == "identifier") + if names: + enum_name = _read_text(names[0], source) + line = node.start_point[0] + 1 + enum_nid = _make_id(stem, enum_name) + add_node(enum_nid, enum_name, line) + add_edge(scope_nid, enum_nid, "defines", line) + for member in names[1:]: + m_name = _read_text(member, source) + m_line = member.start_point[0] + 1 + m_nid = _make_id(stem, enum_name, m_name) + add_node(m_nid, m_name, m_line) + add_edge(enum_nid, m_nid, "contains", m_line) + return + for child in node.children: + walk(child, scope_nid) + return + # Short function: foo(x) = expr if t == "assignment": lhs = node.children[0] if node.children else None @@ -279,7 +333,7 @@ def extract_julia(path: Path) -> dict: # the boundary check returning early on the top-level node itself. # Skip the "signature" child — it contains the function's own call_expression # which would create a self-loop. - if body_node.type == "function_definition": + if body_node.type in ("function_definition", "macro_definition"): for child in body_node.children: if child.type != "signature": walk_calls(child, func_nid) diff --git a/tests/test_languages.py b/tests/test_languages.py index 4508bbfdd..9b239be5f 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -2235,6 +2235,55 @@ def test_julia_abstract_type_with_supertype_is_extracted(tmp_path): assert ("Dog", "Animal") in _edge_labels(r, "inherits"), "abstract inherits edge dropped" +def test_julia_macro_definition_is_extracted(tmp_path): + """`macro name(...) ... end` must be a definition, with the calls in its + body attributed to it. Macros are first-class Julia definitions and were + dropped entirely.""" + f = tmp_path / "macros.jl" + f.write_text( + "function helper(x)\n" + " return x + 1\n" + "end\n" + "macro sayhello(name)\n" + " return :( helper($name) )\n" + "end\n" + ) + r = extract_julia(f) + assert "error" not in r + labels = [n["label"] for n in r["nodes"]] + assert "@sayhello" in labels, "macro definition dropped" + # the macro body's call to a helper is credited to the macro, not a self-loop + assert ("@sayhello", "helper") in _edge_labels(r, "calls") + assert ("@sayhello", "@sayhello") not in _edge_labels(r, "calls") + + +def test_julia_enum_and_members_are_extracted(tmp_path): + """`@enum` defines a type and its members, in both the inline and the + begin/end block forms. The whole macrocall was previously ignored.""" + f = tmp_path / "enums.jl" + f.write_text( + "@enum Fruit apple orange banana\n" + "@enum Color begin\n" + " red\n" + " green\n" + "end\n" + ) + r = extract_julia(f) + assert "error" not in r + labels = {n["label"] for n in r["nodes"]} + assert {"Fruit", "apple", "orange", "banana", "Color", "red", "green"} <= labels + contains = _edge_labels(r, "contains") + assert {("Fruit", "apple"), ("Fruit", "banana"), + ("Color", "red"), ("Color", "green")} <= contains + # the members hang off their enum, not the file + file_nid = next(n["id"] for n in r["nodes"] if n["label"] == "enums.jl") + lab = {n["id"]: n["label"] for n in r["nodes"]} + assert not [ + e for e in r["edges"] + if e["source"] == file_nid and lab.get(e["target"]) in {"apple", "red"} + ] + + def test_julia_struct_field_type_context(): r = extract_julia(FIXTURES / "sample.jl") assert ("Point", "Float64") in _edge_labels(r, "references", "field")