Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
defe78a
fix(rust): keep resolvable builtin-named call sites (repo_names clause)
Aug 1, 2026
dbb0928
fix(rust): multi-bound generic dispatch survives impl-less marker traits
Aug 1, 2026
ac5ea7d
fix(rust): don't harvest phantom calls from macro string literals
Aug 1, 2026
9e7db3b
fix(rust): don't bleed nested-fn call sites into the outer fn
Aug 1, 2026
638e9b4
fix(rust): disambiguate same-named methods so neither is silently dro…
Aug 1, 2026
3ce264f
fix(rust): extract impls on non-nominal Self types (primitive/array/t…
Aug 1, 2026
0325b57
fix(rust): impl-level generic bounds dispatch to conformers (blanket-…
Aug 1, 2026
5c1b68d
chore(rust): add missing Set import (K) + decorators in unit metadata…
Aug 1, 2026
7dba2b6
fix(rust): type let-bindings by the assoc fn's real return type
Aug 1, 2026
3bf8f77
fix(rust): infer let-binding type from a free-fn return type (blackou…
Aug 1, 2026
44d4f98
chore(rust): drop bug-id/session provenance from fix comments
Aug 1, 2026
ae8fc4a
chore(rust): drop bug-id/session labels from test docstrings
Aug 1, 2026
185e480
chore(rust): drop inherited session labels from parser comments
Aug 1, 2026
c02f3a4
test(rust): plant a .rs file in the deep-nest scanner-contract case
Aug 1, 2026
0791d78
test(rust): rename rust test helper to avoid _helpers sys.modules col…
Aug 1, 2026
a0bd007
test(go): add rust to the Go registry parity expected-languages list
Aug 1, 2026
9aa5a4a
Decline return-type/import inference on ambiguity or shadowing
Aug 2, 2026
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
2 changes: 1 addition & 1 deletion apps/openant-cli/internal/languages/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestSupportedMatchesConfig(t *testing.T) {
if err != nil {
t.Fatalf("Supported() error: %v", err)
}
want := []string{"c", "go", "javascript", "php", "python", "ruby", "swift", "zig"}
want := []string{"c", "go", "javascript", "php", "python", "ruby", "rust", "swift", "zig"}
if len(got) != len(want) {
t.Fatalf("Supported() = %v, want %v", got, want)
}
Expand Down
217 changes: 202 additions & 15 deletions libs/openant-core/parsers/rust/call_graph_builder.py

Large diffs are not rendered by default.

105 changes: 100 additions & 5 deletions libs/openant-core/parsers/rust/function_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Set, Tuple

from utilities.file_io import write_json

Expand Down Expand Up @@ -75,6 +75,17 @@ def _load_rust_language() -> Language:
# `impl` block, or as the RHS of a `let x: <type> = ...` annotation.
_TYPE_NODE_KINDS = ("type_identifier", "generic_type", "scoped_type_identifier")

# Non-nominal Self-type node kinds that are absent from _TYPE_NODE_KINDS but CAN be
# an impl target: `impl Trait for u32 / [u8;4] / (i32,i32) / () / &T / *const T /
# dyn X`. Collected in _handle_impl so their methods are extracted; for the
# genuinely non-nominal ones _bare_type_name returns None and _handle_impl falls
# back to the raw type text, while reference/dynamic types unwrap to their nominal
# base as usual.
_IMPL_SELF_EXTRA_KINDS = (
"primitive_type", "array_type", "tuple_type", "unit_type",
"reference_type", "pointer_type", "dynamic_type",
)


def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]:
"""Reduce a type node to its bare (unqualified, non-generic) name.
Expand Down Expand Up @@ -106,16 +117,16 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]:
return _text(last, source) if last is not None else None
if t == "reference_type":
# `&Point` / `&mut Point` / `&'a Point` -> unwrap to Point;
# `&dyn Shape` -> unwrap through the dynamic_type to Shape (val_3_19).
# `&dyn Shape` -> unwrap through the dynamic_type to Shape.
for child in node.children:
if child.type in _TYPE_NODE_KINDS or child.type == "dynamic_type":
return _bare_type_name(child, source)
return None
if t == "dynamic_type":
# `dyn Shape` / `dyn Shape + Send` -> the trait's bare name, so a
# `&dyn Shape` receiver types as `Shape` and dispatches to Shape's
# conformers via trait_impls (val_3_19; `dyn Shape` is a `dynamic_type`
# node, verified against the installed grammar per pr_2_1).
# conformers via trait_impls (`dyn Shape` is a `dynamic_type`
# node, verified against the installed grammar).
for child in node.children:
r = _bare_type_name(child, source)
if r:
Expand All @@ -129,6 +140,53 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]:
return None


def _impl_generic_bounds(impl_node: Node, source: bytes) -> Dict[str, List[str]]:
"""Map an impl block's OWN generic param letter -> its bound trait(s).

`impl<T: Shape + Draw> Foo<T>` and `impl<T> Foo<T> where T: Shape` both yield
`{"T": ["Shape", ...]}`. Only the impl header's own generics (the `<...>` before
the self type, plus the where-clause) are read. Threaded onto each method so a
receiver typed as the impl's generic param (`x: &T`) dispatches to the bound
trait's conformers -- the SAME reachability-safe closure fn-level bounds use --
instead of falling to a bare lookup on the letter `T` (which a blanket impl's
pseudo-type `T` would poison). Mirrors CallGraphBuilder._collect_type_param_bounds.
"""
bounds: Dict[str, List[str]] = {}

def _traits(tb: Node) -> List[str]:
return [_text(c, source) for c in tb.children if c.type == "type_identifier"]

def _add(param: Optional[str], tb: Node) -> None:
if not param:
return
bounds.setdefault(param, [])
for t in _traits(tb):
if t not in bounds[param]:
bounds[param].append(t)

def _param(node: Node) -> None:
pid = None
for cc in node.children:
if cc.type == "type_identifier" and pid is None:
pid = _text(cc, source)
elif cc.type == "trait_bounds":
_add(pid, cc)

seen_for = False
for child in impl_node.children:
if child.type == "for":
seen_for = True
elif child.type == "type_parameters" and not seen_for:
for tp in child.children:
if tp.type in ("type_parameter", "constrained_type_parameter"):
_param(tp)
elif child.type == "where_clause":
for wp in child.children:
if wp.type == "where_predicate":
_param(wp)
return bounds


def _text(node: Optional[Node], source: bytes) -> str:
if node is None:
return ""
Expand Down Expand Up @@ -403,7 +461,7 @@ def _handle_impl(
if cc.type == "type_identifier":
impl_generics.add(_text(cc, source))
break
if child.type in _TYPE_NODE_KINDS:
if child.type in _TYPE_NODE_KINDS or child.type in _IMPL_SELF_EXTRA_KINDS:
type_nodes.append((child, seen_for))
elif child.type == "declaration_list":
body = child
Expand All @@ -418,6 +476,14 @@ def _handle_impl(
self_node = type_nodes[0][0] if type_nodes else None

self_type = _bare_type_name(self_node, source)
if not self_type and self_node is not None:
# Non-nominal Self type (primitive `u32`, array `[u8; 4]`, tuple
# `(i32, i32)`, unit `()`): `_bare_type_name` only names nominal types,
# so `impl Serialize for u32` would be dropped ENTIRELY -- every method
# in the block lost from extraction, the graph, and reachability. Fall
# back to the raw type text so the methods are still extracted (keyed by
# that type spelling).
self_type = _text(self_node, source).strip()
trait_name = _bare_type_name(trait_node, source)

if not self_type or body is None:
Expand All @@ -439,6 +505,8 @@ def _handle_impl(
"module_path": ctx["module_path"],
"in_test_scope": ctx["in_test_scope"],
"in_trait_impl": trait_name is not None,
"impl_trait": trait_name,
"impl_type_param_bounds": _impl_generic_bounds(node, source),
}
worklist.append((body, new_ctx))

Expand Down Expand Up @@ -514,7 +582,29 @@ def _handle_function(

module_name = "::".join(ctx["module_path"]) if ctx["module_path"] else None

# Bare return-type name (`-> Widget` -> "Widget"), so a binding
# `let w = Type::assoc()` can be typed by the assoc fn's ACTUAL return type
# rather than the constructor-idiom assumption that it returns `Type`.
rt_node = node.child_by_field_name("return_type")
return_type = _bare_type_name(rt_node, source) if rt_node is not None else None

func_id = f"{file_path}:{qualified_name}"
if func_id in functions:
# Same qualified_name already taken -- e.g. `impl Display for P` and
# `impl Debug for P` both yield `P.fmt`, or an inherent method plus a
# same-named trait method. Without disambiguation the second silently
# clobbers the first (a whole unit lost from the graph AND reachability).
# Append the trait (or `impl`) so both survive. The FIRST occurrence
# keeps the plain id, so class_name-based resolution and existing
# `Type.method` references are unchanged; only the colliding sibling
# gets the `#trait` suffix.
disc = ctx.get("impl_trait") or "impl"
candidate = f"{file_path}:{qualified_name}#{disc}"
n = 2
while candidate in functions:
candidate = f"{file_path}:{qualified_name}#{disc}{n}"
n += 1
func_id = candidate
functions[func_id] = {
"name": name,
"qualified_name": qualified_name,
Expand All @@ -529,6 +619,11 @@ def _handle_function(
"is_exported": is_exported,
"has_self": has_self,
"decorators": attrs,
# Bounds of the enclosing impl's own generics (`impl<T: Shape> Foo<T>`),
# so a receiver typed as `T` in this method dispatches to the trait's
# conformers. Empty for free functions / inherent-non-generic impls.
"impl_type_param_bounds": ctx.get("impl_type_param_bounds", {}),
"return_type": return_type,
}

block = None
Expand Down
3 changes: 3 additions & 0 deletions libs/openant-core/parsers/rust/unit_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ def _generate_unit(self, func_id: str, func_info: Dict[str, Any]) -> Dict[str, A
"generator": "rust_unit_generator.py",
"direct_calls": direct_calls,
"direct_callers": direct_callers,
# Parity with the Swift parser's unit metadata: carry the function's
# attributes/decorators onto dataset units.
"decorators": func_info.get("decorators", []),
},
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""`let x = Type::assoc()` should type `x` by the assoc fn's ACTUAL return
type, not the blanket assumption that `Type::assoc()` returns `Type`. `Factory::
make() -> Widget` must type the binding as Widget (recovering `w.process()` ->
Widget.process and NOT fabricating Factory.process). The dominant `Type::new() ->
Self` idiom must still resolve to Type."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_factory_return_type_used_for_receiver(tmp_path):
repo = {"lib.rs": """
pub struct Widget; impl Widget { pub fn process(&self) {} }
pub struct Factory; impl Factory { pub fn make() -> Widget { Widget } pub fn process(&self) {} }
pub fn run() { let w = Factory::make(); w.process(); }
"""}
e = edges(build(tmp_path, repo)[1])
assert ("run", "Widget.process") in e, e # real: w is a Widget
assert ("run", "Factory.process") not in e, e # phantom: w is NOT a Factory


def test_new_returns_self_still_resolves(tmp_path):
# the dominant constructor idiom (Type::new() -> Self) must keep working.
repo = {"lib.rs": """
pub struct Point; impl Point { pub fn new() -> Self { Point } pub fn dist(&self) -> f64 { 0.0 } }
pub fn run() { let p = Point::new(); p.dist(); }
"""}
assert ("run", "Point.dist") in edges(build(tmp_path, repo)[1])
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""a multi-bound generic must not lose all edges when one bound trait has
no recorded conformers (marker/blanket/derive/cross-crate impls are invisible to
the extractor). An unseen conformer set is 'unconstrained', not 'empty' — it must
not annihilate the edges the other bounds establish (reachability over-approx)."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_multi_bound_survives_impl_less_marker(tmp_path):
repo = {"lib.rs": """
pub trait Shape { fn area(&self) -> f64; }
pub struct Circle; impl Shape for Circle { fn area(&self) -> f64 { 1.0 } }
pub struct Square; impl Shape for Square { fn area(&self) -> f64 { 2.0 } }
pub trait Marker {}
impl<X: Shape> Marker for X {}
pub fn total<B: Shape + Marker>(b: &B) -> f64 { b.area() }
"""}
e = edges(build(tmp_path, repo)[1])
assert ("total", "Circle.area") in e, e
assert ("total", "Square.area") in e, e
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""RUST_BUILTINS filter must not drop TYPED/resolvable method-call edges.

A method call whose method name happens to be in RUST_BUILTINS (get/parse/new/...)
on a KNOWN receiver type must still resolve — the builtin guard is a precision knob
for the UNKNOWN-receiver fallback only (see _resolve_unknown_receiver_method), not a
reason to delete a fully-resolvable typed method edge at extraction time.
"""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_typed_cross_file_builtin_named_method_resolves(tmp_path):
repo = {
"a.rs": "pub struct Cache;\nimpl Cache { pub fn get(&self) -> i32 { secret() } }\nfn secret() -> i32 { 1 }\n",
"b.rs": "use crate::a::Cache;\npub fn run(c: &Cache) -> i32 { c.get() }\n",
}
e = edges(build(tmp_path, repo)[1])
# 'get' is in RUST_BUILTINS but the receiver is typed (&Cache) and Cache::get is
# unambiguous -> the edge MUST exist (previously dropped by the pre-resolution filter).
assert ("run", "Cache.get") in e, e


def test_bare_builtin_named_free_fn_resolves(tmp_path):
# a bare call to a free fn named like a builtin ('parse') is a real edge.
repo = {
"a.rs": "pub fn parse() -> i32 { 1 }\n",
"b.rs": "use crate::a::parse;\npub fn run() -> i32 { parse() }\n",
}
e = edges(build(tmp_path, repo)[1])
assert ("run", "parse") in e, e
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _helpers import build # noqa: E402
from _rust_helpers import build # noqa: E402

_REPO = {
"lib.rs": """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""a receiver bound from a free-function call
`let c = load()` where `load() -> Cfg` must type `c` as Cfg, so `c.validate()`
resolves PRECISELY to Cfg.validate -- recovering the unknown-receiver blackout the
unknown-receiver decline gate would otherwise cause, with NO phantom to a same-named method on an
unrelated type. This makes the receiver KNOWN rather than relaxing the gate."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_free_fn_return_type_types_the_binding(tmp_path):
repo = {
"a.rs": "pub struct Cfg; impl Cfg { fn validate(&self) {} }\n"
"pub struct Form; impl Form { fn validate(&self) {} }\n"
"pub fn load() -> Cfg { Cfg }\n",
"b.rs": "use crate::a::{Cfg, Form, load};\npub fn run() { let c = load(); c.validate(); }\n",
}
e = edges(build(tmp_path, repo)[1])
assert ("run", "Cfg.validate") in e, e # recovered precisely via load() -> Cfg
assert ("run", "Form.validate") not in e, e # no phantom to the other same-named type
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import sys

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _helpers import build, edges # noqa: E402
from _rust_helpers import build, edges # noqa: E402


def test_inline_bound_dispatches_to_conformers(tmp_path):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""a method receiver typed as an IMPL-level generic param (`impl<T: Shape>
Holder<T> { fn m(&self, x: &T) { x.area() } }`) must dispatch to the bound trait's
conformers -- same as a fn-level bound. Previously the impl-level bound was invisible
(only fn-level generics were read), so `x: T` fell to a bare-name lookup on the
letter `T`, which an unrelated blanket `impl<U: _> Audit for U` had poisoned with a
pseudo-type `T.area` -> phantom edge + the real conformer edge dropped."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_impl_level_bound_dispatches_to_conformers(tmp_path):
repo = {"lib.rs": """
pub trait Shape { fn area(&self) -> f64; }
pub struct Circle; impl Shape for Circle { fn area(&self) -> f64 { 1.0 } }
pub trait Debugx {}
pub trait Audit { fn area(&self); }
impl<U: Debugx> Audit for U { fn area(&self) {} }
pub struct Holder<T> { x: T }
impl<T: Shape> Holder<T> { pub fn measure(&self, x: &T) -> f64 { x.area() } }
"""}
e = edges(build(tmp_path, repo)[1])
assert ("Holder.measure", "Circle.area") in e, e # real conformer edge recovered
assert ("Holder.measure", "T.area") not in e, e # blanket pseudo-type phantom gone
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""the macro token-tree regex scan must not harvest call-shaped text from
INSIDE string literals. `println!("call init() first")` must not fabricate an edge
to an unrelated `init`; real calls OUTSIDE the literal (`format!("{}", foo())`)
must still be recovered."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import build, edges # noqa: E402


def test_no_phantom_from_call_shaped_string_literal(tmp_path):
repo = {"lib.rs": 'pub fn connect() { panic!("not ready: call init() first"); }\npub fn init() {}\n'}
e = edges(build(tmp_path, repo)[1])
assert ("connect", "init") not in e, e


def test_real_call_outside_literal_still_recovered(tmp_path):
repo = {"lib.rs": 'pub fn caller() { println!("{}", helper()); }\npub fn helper() -> i32 { 1 }\n'}
e = edges(build(tmp_path, repo)[1])
assert ("caller", "helper") in e, e
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""two same-named methods on one type (the ubiquitous `impl Display for P`
+ `impl Debug for P`, both `fn fmt`) must both be extracted. Previously both mapped
to func_id `file:P.fmt` and the second silently clobbered the first (data loss:
a whole unit vanished from the graph and reachability)."""
import pathlib, sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from _rust_helpers import extract # noqa: E402


def test_same_name_methods_both_extracted(tmp_path):
ext = extract(tmp_path, {"a.rs": """
pub struct P;
impl std::fmt::Display for P { fn fmt(&self) {} }
impl std::fmt::Debug for P { fn fmt(&self) {} }
"""})
fmts = [f for f in ext["functions"].values() if f["name"] == "fmt" and f["class_name"] == "P"]
assert len(fmts) == 2, [f["qualified_name"] for f in fmts]


def test_inherent_plus_trait_same_name_both_extracted(tmp_path):
ext = extract(tmp_path, {"a.rs": """
pub trait Draw { fn render(&self); }
pub struct W;
impl W { fn render(&self) -> i32 { 1 } }
impl Draw for W { fn render(&self) {} }
"""})
renders = [f for f in ext["functions"].values() if f["name"] == "render" and f["class_name"] == "W"]
assert len(renders) == 2, [f["qualified_name"] for f in renders]
Loading
Loading