Skip to content

Commit cf3648e

Browse files
ayarodionovclaude
andcommitted
fix(julia): extract return-typed, qualified, operator and functor methods; broadcast/pipe calls
- Unwrap `::RetType` (alongside `where`) around a definition's call head, so `function f(x)::Float64` is no longer dropped. - Name method definitions via `_def_name`: `Base.show`, `Base.:+`, bare operators, and functors `(c::Circle)(x)`. The id uses the bare name so all methods of a generic function share one node; the label keeps the qualifier. - Emit `calls` edges for broadcast calls (`f.(xs)`) and pipes (`x |> f`, `.|>`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b4e6307 commit cf3648e

2 files changed

Lines changed: 137 additions & 33 deletions

File tree

‎graphify/extractors/julia.py‎

Lines changed: 74 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -158,29 +158,58 @@ def _type_head_params(type_head) -> set[str]:
158158
return params
159159

160160
def _unwrap_call_expression(node):
161-
"""Return the `call_expression` at or under `node`, unwrapping a
162-
`where_expression` (`f(x::T) where {T}` nests the call head one level
163-
deeper); otherwise None."""
164-
if node is None:
165-
return None
166-
if node.type == "call_expression":
161+
"""Return the `call_expression` at or under `node`, unwrapping any
162+
`where_expression` (`f(x::T) where {T}`) and `typed_expression`
163+
(`f(x)::Float64`) wrappers around the call head; otherwise None."""
164+
while node is not None and node.type in ("where_expression", "typed_expression"):
165+
node = node.children[0] if node.children else None
166+
if node is not None and node.type == "call_expression":
167167
return node
168-
if node.type == "where_expression":
169-
return next((c for c in node.children if c.type == "call_expression"), None)
170168
return None
171169

172-
def _func_name_from_signature(sig_node) -> str | None:
173-
"""Extract function name from a Julia signature node (call_expression > identifier).
170+
def _def_name(call) -> tuple[str, str] | None:
171+
"""(bare_name, label) for the callee of a method definition.
172+
173+
Handles `foo`, `+`, `Base.show`, `Base.:+`, and functors `(c::Circle)(x)`.
174+
The id uses the bare name so methods of one generic function share a node
175+
(multiple dispatch) and in-file calls resolve to it; the label keeps the
176+
qualifier so `Base.show()` extensions stay recognisable.
177+
"""
178+
if call is None or not call.children:
179+
return None
180+
callee = call.children[0]
181+
if callee.type in ("identifier", "operator"):
182+
name = _read_text(callee, source)
183+
return name, name
184+
if callee.type == "field_expression" and callee.children:
185+
last = callee.children[-1]
186+
if last.type == "quote_expression": # Base.:+
187+
last = next((c for c in last.children
188+
if c.type in ("operator", "identifier")), None)
189+
if last is not None and last.type in ("identifier", "operator"):
190+
return _read_text(last, source), _read_text(callee, source)
191+
return None
192+
if callee.type == "parenthesized_expression":
193+
# functor: (c::Circle)(x) or (::Circle)(x) -> call operator on Circle
194+
typed = next((c for c in callee.children
195+
if c.type in ("typed_expression", "unary_typed_expression")), None)
196+
if typed is not None:
197+
ids = [c for c in typed.children if c.type == "identifier"]
198+
if ids:
199+
tname = _read_text(ids[-1], source)
200+
return tname + "#call", f"({tname})"
201+
return None
202+
203+
def _func_name_from_signature(sig_node) -> tuple[str, str] | None:
204+
"""(bare_name, label) from a Julia `signature` node, or None.
174205
175-
A `where` clause wraps the call head in a `where_expression`, so unwrap
176-
that before reading the callee identifier.
206+
`where` clauses and `::RetType` annotations wrap the call head, so
207+
unwrap those before reading the callee.
177208
"""
178209
for child in sig_node.children:
179210
call = _unwrap_call_expression(child)
180-
if call is not None and call.children:
181-
callee = call.children[0]
182-
if callee.type == "identifier":
183-
return _read_text(callee, source)
211+
if call is not None:
212+
return _def_name(call)
184213
return None
185214

186215
def walk_calls(body_node, func_nid: str) -> None:
@@ -204,6 +233,18 @@ def walk_calls(body_node, func_nid: str) -> None:
204233
target_nid = _make_id(stem, method_name)
205234
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
206235
confidence="EXTRACTED", context="call")
236+
# Broadcast call: foo.(xs)
237+
elif t == "broadcast_call_expression" and body_node.children:
238+
callee = body_node.children[0]
239+
if callee.type == "identifier":
240+
add_edge(func_nid, _make_id(stem, _read_text(callee, source)), "calls",
241+
body_node.start_point[0] + 1, confidence="EXTRACTED", context="call")
242+
# Pipe: x |> foo, xs .|> foo
243+
elif t == "binary_expression" and len(body_node.children) == 3:
244+
op, rhs = body_node.children[1], body_node.children[2]
245+
if _read_text(op, source) in ("|>", ".|>") and rhs.type == "identifier":
246+
add_edge(func_nid, _make_id(stem, _read_text(rhs, source)), "calls",
247+
body_node.start_point[0] + 1, confidence="EXTRACTED", context="call")
207248
for child in body_node.children:
208249
walk_calls(child, func_nid)
209250

@@ -278,11 +319,12 @@ def walk(node, scope_nid: str) -> None:
278319
if t == "function_definition":
279320
sig_node = next((c for c in node.children if c.type == "signature"), None)
280321
if sig_node:
281-
func_name = _func_name_from_signature(sig_node)
282-
if func_name:
322+
named = _func_name_from_signature(sig_node)
323+
if named:
324+
func_name, label = named
283325
func_nid = _make_id(stem, func_name)
284326
line = node.start_point[0] + 1
285-
add_node(func_nid, f"{func_name}()", line)
327+
add_node(func_nid, f"{label}()", line)
286328
add_edge(scope_nid, func_nid, "defines", line)
287329
function_bodies.append((func_nid, node))
288330
return
@@ -294,7 +336,8 @@ def walk(node, scope_nid: str) -> None:
294336
if t == "macro_definition":
295337
sig_node = next((c for c in node.children if c.type == "signature"), None)
296338
if sig_node:
297-
macro_name = _func_name_from_signature(sig_node)
339+
named = _func_name_from_signature(sig_node)
340+
macro_name = named[0] if named else None
298341
if macro_name:
299342
macro_nid = _make_id(stem, "@" + macro_name)
300343
line = node.start_point[0] + 1
@@ -366,19 +409,17 @@ def _member_ident(n):
366409
# Short function: foo(x) = expr (and the `where` form: g(x::T) where {T} = expr)
367410
if t == "assignment":
368411
lhs = node.children[0] if node.children else None
369-
call = _unwrap_call_expression(lhs)
370-
if call is not None and call.children:
371-
callee = call.children[0]
372-
if callee.type == "identifier":
373-
func_name = _read_text(callee, source)
374-
func_nid = _make_id(stem, func_name)
375-
line = node.start_point[0] + 1
376-
add_node(func_nid, f"{func_name}()", line)
377-
add_edge(scope_nid, func_nid, "defines", line)
378-
# Only walk the RHS (index 2 after lhs and operator) to avoid self-loops
379-
rhs = node.children[-1] if len(node.children) >= 3 else None
380-
if rhs:
381-
function_bodies.append((func_nid, rhs))
412+
named = _def_name(_unwrap_call_expression(lhs))
413+
if named:
414+
func_name, label = named
415+
func_nid = _make_id(stem, func_name)
416+
line = node.start_point[0] + 1
417+
add_node(func_nid, f"{label}()", line)
418+
add_edge(scope_nid, func_nid, "defines", line)
419+
# Only walk the RHS (index 2 after lhs and operator) to avoid self-loops
420+
rhs = node.children[-1] if len(node.children) >= 3 else None
421+
if rhs:
422+
function_bodies.append((func_nid, rhs))
382423
return
383424

384425
# Using / Import

‎tests/test_languages.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5665,3 +5665,66 @@ def test_kotlin_bracketed_annotations_emits_multiple_attribute_edges(tmp_path):
56655665
refs = _edge_labels(result, "references", "attribute")
56665666
assert ("Foo", "Inject") in refs
56675667
assert ("Foo", "VisibleForTesting") in refs
5668+
5669+
5670+
_JULIA_DISPATCH_SRC = '''
5671+
module Demo
5672+
abstract type Shape end
5673+
struct Circle{T<:Real} <: Shape
5674+
r::T
5675+
end
5676+
helper(x) = 2x
5677+
function param(x::T) where {T<:Real}
5678+
helper(x)
5679+
end
5680+
function rettype(x)::Float64
5681+
param(x)
5682+
end
5683+
function Base.show(io::IO, c::Circle)
5684+
print(io, c.r)
5685+
end
5686+
Base.length(c::Circle) = 1
5687+
area(c::Circle{T}) where T = pi * c.r^2
5688+
(c::Circle)(x) = area(c) * x
5689+
Base.:+(a::Circle, b::Circle) = Circle(a.r + b.r)
5690+
@inline function fast(x)
5691+
x |> helper
5692+
end
5693+
bcast(xs) = helper.(xs)
5694+
end
5695+
'''
5696+
5697+
5698+
def _julia_dispatch(tmp_path):
5699+
f = tmp_path / "dispatch.jl"
5700+
f.write_text(_JULIA_DISPATCH_SRC)
5701+
r = extract_julia(f)
5702+
labels = {n["id"]: n["label"] for n in r["nodes"]}
5703+
return r, labels
5704+
5705+
5706+
def test_julia_where_rettype_qualified_operator_functor_defs(tmp_path):
5707+
_, labels = _julia_dispatch(tmp_path)
5708+
got = set(labels.values())
5709+
for want in ("param()", "rettype()", "Base.show()", "Base.length()",
5710+
"area()", "(Circle)()", "Base.:+()", "fast()", "bcast()"):
5711+
assert want in got, want
5712+
5713+
5714+
def test_julia_parametric_struct_and_no_typeparam_nodes(tmp_path):
5715+
r, labels = _julia_dispatch(tmp_path)
5716+
assert "Circle" in labels.values()
5717+
assert "T" not in labels.values()
5718+
inh = [(labels[e["source"]], labels.get(e["target"])) for e in r["edges"]
5719+
if e["relation"] == "inherits"]
5720+
assert ("Circle", "Shape") in inh
5721+
5722+
5723+
def test_julia_broadcast_and_pipe_calls(tmp_path):
5724+
r, labels = _julia_dispatch(tmp_path)
5725+
calls = {(labels.get(e["source"]), labels.get(e["target"])) for e in r["edges"]
5726+
if e["relation"] == "calls"}
5727+
assert ("fast()", "helper()") in calls
5728+
assert ("bcast()", "helper()") in calls
5729+
assert ("param()", "helper()") in calls
5730+
assert ("rettype()", "param()") in calls

0 commit comments

Comments
 (0)