Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions libs/openant-core/parsers/zig/call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ def build_call_graph(self) -> None:

for call_name in calls:
resolved_ids = self._resolve_call(
call_name, file_path, name_to_ids, alias_to_target
call_name, file_path, name_to_ids, alias_to_target, func_id
)
for resolved_id in resolved_ids:
if resolved_id != func_id: # No self-calls
Expand Down Expand Up @@ -269,17 +269,19 @@ def _build_name_index(self) -> Dict[str, List[str]]:

def _build_alias_index(
self, name_to_ids: Dict[str, List[str]]
) -> Dict[str, Dict[str, str]]:
"""Index simple const fn-aliases per file: `const f = handler;` -> {f: handler}.
) -> Dict[str, Dict[str, Optional[str]]]:
"""Index simple const fn-aliases per function: `const f = handler;` -> {f: handler}.

Only bindings whose right-hand side is a bare identifier naming a known
function are tracked (a genuine fn alias), so arbitrary const dataflow
(`const x = 1;`) is ignored. Scoped per file to avoid cross-file leaks.
(`const x = 1;`) is ignored. Scoped per FUNCTION (keyed by func_id), not
per file: two functions in the same file may bind the same alias name to
different targets (`const doit = foo` vs `const doit = bar`), and each
caller must resolve to its own target rather than clobbering the other.
"""
alias_to_target: Dict[str, Dict[str, str]] = defaultdict(dict)
alias_to_target: Dict[str, Dict[str, Optional[str]]] = defaultdict(dict)

for func_info in self.functions.values():
file_path = func_info.get("file_path", "")
for func_id, func_info in self.functions.items():
code = func_info.get("code", "")
if not code:
continue
Expand All @@ -291,7 +293,7 @@ def _build_alias_index(
tree.root_node,
code.encode("utf-8"),
name_to_ids,
alias_to_target[file_path],
alias_to_target[func_id],
)

return alias_to_target
Expand All @@ -301,7 +303,7 @@ def _collect_aliases_from_node(
node: Node,
source: bytes,
name_to_ids: Dict[str, List[str]],
aliases: Dict[str, str],
aliases: Dict[str, Optional[str]],
) -> None:
"""Collect `const <alias> = <known-fn>;` bindings from a parse tree."""
if node.type in ("variable_declaration", "VarDecl"):
Expand All @@ -312,7 +314,10 @@ def _collect_aliases_from_node(
if len(ident_children) == 2:
alias_name = self._get_node_text(ident_children[0], source)
target_name = self._get_node_text(ident_children[1], source)
# Only record when the target is a known function name.
# Only record when the target is a known function name. A Zig
# `const` binding is immutable and cannot be rebound within a
# function, so a plain assignment (scoped per function by the
# caller) is sufficient here.
if alias_name and target_name in name_to_ids:
aliases[alias_name] = target_name

Expand Down Expand Up @@ -438,7 +443,8 @@ def _resolve_call(
call_name: str,
caller_file: str,
name_to_ids: Dict[str, List[str]],
alias_to_target: Dict[str, Dict[str, str]] | None = None,
alias_to_target: Dict[str, Dict[str, Optional[str]]] | None = None,
caller_id: Optional[str] = None,
) -> List[str]:
"""
Resolve a call name to function ID(s).
Expand All @@ -448,10 +454,12 @@ def _resolve_call(
2. Imported files
3. Unique name match
"""
# Resolve a same-file const fn-alias (`const f = handler; f()`) to its
# target function name before looking up candidates.
if alias_to_target is not None:
target = alias_to_target.get(caller_file, {}).get(call_name)
# Resolve a const fn-alias (`const f = handler; f()`) to its target
# function name before looking up candidates. Aliases are keyed by the
# CALLER function (not the file) so a same-named alias in another
# function in the same file cannot clobber this one.
if alias_to_target is not None and caller_id is not None:
target = alias_to_target.get(caller_id, {}).get(call_name)
if target is not None:
call_name = target

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,40 @@ def test_bug41_const_alias_call_edge():
assert "m.zig:handler" in cg.get("m.zig:direct", []), (
f"Direct call edge regressed, got call_graph={cg}"
)


def test_bugB9_per_function_const_alias_not_clobbered():
"""[BUG B9] `const doit = X` in one fn must not clobber `const doit = Y` in another.

The local const-alias map was FILE-keyed, so two functions in the same file
that each bind the same alias name to a DIFFERENT target overwrote each other:
both callers resolved to whichever binding was collected last. Keyed by the
caller function, each `doit()` must resolve to ITS OWN target.
"""
src = (
"fn foo() void {}\n"
"fn bar() void {}\n"
"fn caller_a() void {\n"
" const doit = foo;\n"
" doit();\n"
"}\n"
"fn caller_b() void {\n"
" const doit = bar;\n"
" doit();\n"
"}\n"
)
cg = _run_pipeline(src)["call_graph"]
# caller_a's `doit` is foo; caller_b's `doit` is bar.
assert "m.zig:foo" in cg.get("m.zig:caller_a", []), (
f"Expected caller_a -> foo (its own alias), got call_graph={cg}"
)
assert "m.zig:bar" in cg.get("m.zig:caller_b", []), (
f"Expected caller_b -> bar (its own alias), got call_graph={cg}"
)
# Neither caller must pick up the OTHER function's alias target.
assert "m.zig:bar" not in cg.get("m.zig:caller_a", []), (
f"caller_a wrongly resolved to bar (clobbered alias), got call_graph={cg}"
)
assert "m.zig:foo" not in cg.get("m.zig:caller_b", []), (
f"caller_b wrongly resolved to foo (clobbered alias), got call_graph={cg}"
)
Loading