fix(fortran): extract named generic interfaces as callable nodes

A named generic interface (`interface area; module procedure area_circle,
area_square; end interface`) names a callable that dispatches to its
specific procedures by argument type. The interface block was never turned
into a node, so a call to the generic name dangled — a Fortran function
call_expression only resolves to an in-file node — and the dispatch set
was invisible, even though the specific procedures were extracted. Generic
interfaces (and operator overload sets) are routine in scientific Fortran.

Emit a node for the generic name, link it to the procedures it groups with
`dispatches_to` edges (resolved after the walk, like type-bound
procedures, so a procedure from another module becomes a collapsible
sourceless stub), and the existing call resolution then binds `area(x)`
to it. Unnamed abstract/explicit interfaces keep recursing so their
procedure declarations are still extracted.
This commit is contained in:
rajatnagda45 committed 2026-10-06 09:09:37 +05:30
1 parent 5c7b84792f
commit ccfb4018fb
2 files changed
+91

No files matched your search

+49
View File
@@ -131,6 +131,10 @@ def extract_fortran(path: Path) -> dict:
# procedure. Resolved after the whole tree is walked, once the module's
# internal procedures have their nodes.
type_bound_procs: list[tuple[str, str, int]] = []
# (generic_nid, specific_procedure_name, line) for each procedure grouped
# under a named generic interface. Resolved after the walk, like the
# type-bound procedures above.
generic_procs: list[tuple[str, str, int]] = []
def _fortran_name(stmt_node) -> str | None:
"""Extract name from a *_statement node. Fortran is case-insensitive; lowercase."""
@@ -345,6 +349,42 @@ def extract_fortran(path: Path) -> dict:
walk(child, nid)
return
if t == "interface":
stmt = next((c for c in node.children
if c.type == "interface_statement"), None)
name_node = next((c for c in stmt.children if c.type == "name"),
None) if stmt is not None else None
if name_node is not None:
# Named generic interface (`interface area; module procedure
# area_circle, area_square; end interface`): `area` is a callable
# generic name that dispatches to its specific procedures by
# argument type. It was never a node, so every `area(...)` call
# dangled — a function call_expression only resolves to an
# in-file node. Emit the generic node and link it to the
# procedures it groups, so calls resolve and the dispatch set is
# visible.
gen_name = _read_text(name_node, source).lower()
gen_nid = _make_id(stem, gen_name)
line = node.start_point[0] + 1
add_node(gen_nid, f"{gen_name}()", line)
add_edge(scope_nid, gen_nid, "defines", line)
for proc_stmt in node.children:
if proc_stmt.type != "procedure_statement":
continue
for mn in proc_stmt.children:
if mn.type == "method_name":
generic_procs.append((
gen_nid,
_read_text(mn, source).lower(),
proc_stmt.start_point[0] + 1,
))
return
# Unnamed abstract/explicit interface: recurse so the procedure
# declarations it holds still get nodes (unchanged behaviour).
for child in node.children:
walk(child, scope_nid)
return
if t == "use_statement":
line = node.start_point[0] + 1
# tree-sitter-fortran uses module_name node for the used module
@@ -370,6 +410,15 @@ def extract_fortran(path: Path) -> dict:
if tgt != type_nid:
add_edge(type_nid, tgt, "method", line, context="type_bound_procedure")
# Link each generic interface to the specific procedures it groups. A
# procedure imported from another module resolves to a sourceless stub the
# corpus rewire can collapse (same treatment as type-bound procedures).
for gen_nid, impl_name, line in generic_procs:
tgt = ensure_named_node(impl_name, line)
if tgt != gen_nid:
add_edge(gen_nid, tgt, "dispatches_to", line,
context="generic_interface")
_stmt_headers = {
"subroutine_statement", "function_statement",
"program_statement", "module_statement",
+42
View File
@@ -2626,6 +2626,48 @@ def test_fortran_type_bound_procedures_link_to_the_type(tmp_path):
assert ("circle", "scale") in methods
def test_fortran_generic_interface_is_a_callable_node(tmp_path):
"""A named generic interface (`interface area; module procedure …`) names a
callable that dispatches to its specific procedures.
The interface was never a node, so a call to the generic name dangled (a
function call only resolves to an in-file node) and the dispatch set was
invisible — even though the specific procedures were extracted. The generic
name must be a node, linked to the procedures it groups, so `area(x)`
resolves.
"""
src = tmp_path / "shapes.f90"
src.write_text(
"module shapes\n"
" interface area\n"
" module procedure area_circle, area_square\n"
" end interface\n"
"contains\n"
" real function area_circle(r)\n"
" real :: r\n"
" area_circle = r\n"
" end function\n"
" real function area_square(s)\n"
" real :: s\n"
" area_square = s\n"
" end function\n"
" subroutine report(x)\n"
" real :: x, a\n"
" a = area(x)\n"
" end subroutine\n"
"end module\n",
encoding="utf-8",
)
r = extract_fortran(src)
assert "error" not in r
assert "area()" in [n["label"] for n in r["nodes"]], "generic interface dropped"
dispatch = _edge_labels(r, "dispatches_to", "generic_interface")
assert ("area", "area_circle") in dispatch
assert ("area", "area_square") in dispatch
# A call to the generic name now resolves instead of dangling.
assert ("report", "area") in _edge_labels(r, "calls")
def test_fortran_type_bound_procedure_from_other_module_is_sourceless(tmp_path):
"""A binding to a procedure implemented in another module resolves to a
sourceless stub the corpus rewire can collapse, never a dangling edge."""