diff --git a/apps/openant-cli/internal/languages/registry_test.go b/apps/openant-cli/internal/languages/registry_test.go index 69e6340..e25f81c 100644 --- a/apps/openant-cli/internal/languages/registry_test.go +++ b/apps/openant-cli/internal/languages/registry_test.go @@ -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) } diff --git a/libs/openant-core/parsers/rust/call_graph_builder.py b/libs/openant-core/parsers/rust/call_graph_builder.py index abdcdc7..067eef5 100644 --- a/libs/openant-core/parsers/rust/call_graph_builder.py +++ b/libs/openant-core/parsers/rust/call_graph_builder.py @@ -55,6 +55,18 @@ r"\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*(?:::<[^>]*>)?\s*\(" ) +# String / char literals inside a macro token tree, blanked BEFORE the call-shaped +# scan so call-looking text in a diagnostic message (`panic!("call init() first")`) +# is not harvested as a phantom edge. Covers raw strings (`r"..."`, `r#"..."#`), +# normal strings with escapes, and char literals; Rust lifetimes (`'a`, no closing +# quote) intentionally do not match. Best-effort, matching the scan itself. +_RUST_STR_LITERAL_RE = re.compile( + r'r#"(?:[^"]|"(?!#))*"#' # raw string, one hash + r'|r"[^"]*"' # raw string, no hash + r'|"(?:\\.|[^"\\])*"' # normal string with escapes + r"|'(?:\\.|[^'\\])'" # char literal +) + RUST_BUILTINS = { # core::fmt / println-family macro names (recovered via token-tree scan, # so they can appear as bare "calls" and must be filtered like any std fn) @@ -133,10 +145,18 @@ def build_call_graph(self) -> None: file_path = func_info.get("file_path", "") caller_class = func_info.get("class_name") - var_types = self._collect_var_types(code) + var_types = self._collect_var_types(code, name_to_ids) fn_aliases = self._collect_fn_aliases(code, name_to_ids, file_path) - type_param_bounds = self._collect_type_param_bounds(code) - calls = self._find_calls_in_code(code, file_path) + # Merge the enclosing impl's generic bounds (`impl Foo`, + # recorded by the extractor) with the fn's OWN generics; the fn's own + # bound wins on a letter collision (inner shadows outer). Without the + # impl-level bounds a receiver typed as the impl's `T` would fall to a + # bare lookup on the letter and be hijacked by a blanket pseudo-type (D). + type_param_bounds = { + **func_info.get("impl_type_param_bounds", {}), + **self._collect_type_param_bounds(code), + } + calls = self._find_calls_in_code(code, file_path, name_to_ids) for call in calls: resolved_ids = self._resolve_call( @@ -237,7 +257,10 @@ def _build_name_index(self) -> Dict[str, List[str]]: # -- call-site extraction --------------------------------------------------- - def _find_calls_in_code(self, code: str, caller_file: str) -> List[dict]: + def _find_calls_in_code( + self, code: str, caller_file: str, + name_to_ids: Optional[Dict[str, List[str]]] = None, + ) -> List[dict]: """Return call-site descriptors: {"kind": "bare"|"field"|"scoped", ...}.""" sites: List[dict] = [] if not code: @@ -249,8 +272,20 @@ def _find_calls_in_code(self, code: str, caller_file: str) -> List[dict]: source = code.encode("utf-8") stack = [tree.root_node] + entered_fn = False while stack: node = stack.pop() + if node.type == "function_item": + # The outermost function_item IS this unit; a NESTED `fn` is its own + # extracted unit (`outer::inner`) that collects its own call sites + # under its own generic bounds. Do not descend into a nested fn body + # here — attributing its calls to the outer unit resolves them under + # the outer fn's (wrong) bounds and fabricates phantom edges. Closures + # (closure_expression) are NOT function_items, so they still belong to + # this unit, which is correct (they capture the outer bounds). + if entered_fn: + continue + entered_fn = True if node.type == "call_expression": callee = node.children[0] if node.children else None site = self._describe_callee(callee, source) @@ -260,11 +295,24 @@ def _find_calls_in_code(self, code: str, caller_file: str) -> List[dict]: self._scan_macro_body(node, source, sites) stack.extend(node.children) + # The RUST_BUILTINS set is a precision guard for the UNKNOWN-receiver + # fallback path only (see `_resolve_unknown_receiver_method`), NOT a reason + # to delete a resolvable call at extraction time. A builtin-named site is + # dropped here ONLY when the name is a pure std/library name — i.e. it does + # NOT name any known repo function/method (`repo_names`) and is not shadowed + # in the caller's own file. When the name IS a real repo function/method, + # the site is kept and resolution decides precisely (typed dispatch) or + # conservatively (the unknown-receiver builtin guard). This mirrors the + # Swift parser's filter, whose `text in repo_names` clause keeps exactly + # these real calls; the Rust port had dropped that clause, silently + # deleting typed cross-file `.get()/.parse()/...`-style edges. shadowing = self._same_file_function_names(caller_file) + repo_names = set(name_to_ids) if name_to_ids else set() filtered = [] for site in sites: bare = site.get("bare_filter_name") - if bare is None or bare in shadowing or bare not in RUST_BUILTINS: + if (bare is None or bare in shadowing or bare not in RUST_BUILTINS + or bare in repo_names): filtered.append(site) return filtered @@ -372,6 +420,8 @@ def _scan_macro_body(self, node: Node, source: bytes, sites: List[dict]) -> None if macro_name not in _SCANNABLE_MACROS or token_tree is None: return text = self._text(token_tree, source) + # Blank literals so a call-shaped substring inside a string is not scanned. + text = _RUST_STR_LITERAL_RE.sub(" ", text) for match in _MACRO_CALL_RE.finditer(text): call_name = match.group(1) if "." in call_name: @@ -403,7 +453,9 @@ def _text(self, node: Node, source: bytes) -> str: # -- receiver static-type inference ---------------------------------------- - def _collect_var_types(self, code: str) -> Dict[str, str]: + def _collect_var_types( + self, code: str, name_to_ids: Optional[Dict[str, List[str]]] = None, + ) -> Dict[str, str]: """Map local variable/parameter name -> its declared/inferred type. Sources, in order of confidence: parameter type annotations @@ -438,6 +490,27 @@ def _set(name: Optional[str], typ: Optional[str]) -> None: return var_types source = code.encode("utf-8") + # A local `let` bound to a CLOSURE shadows a same-named free function as + # the call target: `let helper = || ...; let x = helper()` calls the + # closure, not a repo `fn helper`. Collect only closure bindings -- a + # non-callable rebind (`let load = 5`) can never be `load()`-called, so it + # does not shadow a function call and must not block return-type inference. + local_closures: Set[str] = set() + lstack = [tree.root_node] + while lstack: + n = lstack.pop() + if n.type == "let_declaration": + name = None + seen_eq = False + for c in n.children: + if c.type == "identifier" and name is None and not seen_eq: + name = self._text(c, source) + elif c.type == "=": + seen_eq = True + elif seen_eq and c.type == "closure_expression" and name: + local_closures.add(name) + lstack.extend(n.children) + stack = [tree.root_node] while stack: node = stack.pop() @@ -462,11 +535,60 @@ def _set(name: Optional[str], typ: Optional[str]) -> None: name = self._text(c, source) break if name: - _set(name, self._infer_let_type(node, source)) + _set(name, self._infer_let_type( + node, source, name_to_ids, local_closures)) stack.extend(node.children) return var_types - def _infer_let_type(self, node: Node, source: bytes) -> Optional[str]: + def _assoc_return_type( + self, qualifier: str, leaf: Optional[str], + name_to_ids: Optional[Dict[str, List[str]]], + ) -> Optional[str]: + """Return the recorded return type of associated fn `qualifier::leaf`, if + known and not `Self`. Lets `let w = Factory::make()` type `w` as make()'s + real return type (Widget) instead of the constructor-idiom guess (Factory). + `Self` returns fall through to the qualifier (the `Type::new() -> Self` case). + """ + if not leaf or not name_to_ids: + return None + rts = set() + for cand in name_to_ids.get(leaf, []): + info = self.functions.get(cand, {}) + if info.get("class_name") == qualifier: + rt = info.get("return_type") + if rt and rt != "Self": + rts.add(rt) + # Two same-named types (`Factory` in different files) with differing + # return types is unresolvable here -- decline rather than guess the + # first, which would fabricate a wrong-type edge. Mirrors the + # uniqueness guard in `_free_fn_return_type`. + return next(iter(rts)) if len(rts) == 1 else None + + def _free_fn_return_type( + self, fn_name: str, name_to_ids: Optional[Dict[str, List[str]]], + ) -> Optional[str]: + """Return the recorded return type of a FREE function `fn_name`, if a single + unambiguous non-`Self` type. Lets `let c = load()` (where `load() -> Cfg`) + type `c` as Cfg, so a later `c.method()` resolves precisely instead of hitting + the unknown-receiver gate and blacking out. Precise (typed) -> zero phantom. + """ + if not name_to_ids: + return None + rts = set() + for cand in name_to_ids.get(fn_name, []): + info = self.functions.get(cand, {}) + if info.get("class_name"): # must be a free function, not a method + continue + rt = info.get("return_type") + if rt and rt != "Self": + rts.add(rt) + return next(iter(rts)) if len(rts) == 1 else None + + def _infer_let_type( + self, node: Node, source: bytes, + name_to_ids: Optional[Dict[str, List[str]]] = None, + local_closures: Optional[Set[str]] = None, + ) -> Optional[str]: from .function_extractor import _bare_type_name # `let x: Type = ...;` -- explicit annotation wins. @@ -501,7 +623,22 @@ def _infer_let_type(self, node: Node, source: bytes) -> Optional[str]: if callee is not None and callee.type == "scoped_identifier": qualifier, _leaf = self._split_scoped(callee, source) if qualifier and qualifier not in ("Self",): - return qualifier + # Prefer the assoc fn's ACTUAL return type (`make() -> Widget`) + # over the constructor-idiom assumption that it returns the + # qualifier (`Factory`). Falls back to the qualifier when the + # return type is unknown or `Self` (the `Type::new()` case). + return self._assoc_return_type(qualifier, _leaf, name_to_ids) or qualifier + if callee is not None and callee.type == "identifier": + # `let c = load()` where `load() -> Cfg` -> type c as Cfg, so a + # later `c.method()` resolves precisely (recovering the + # unknown-receiver blackout) with zero phantom. Skip when the + # name is a local closure binding shadowing the free fn -- + # typing from the free fn would be a wrong-type phantom. + fname = self._text(callee, source) + if not (local_closures and fname in local_closures): + rt = self._free_fn_return_type(fname, name_to_ids) + if rt: + return rt return None def _collect_fn_aliases( @@ -511,7 +648,7 @@ def _collect_fn_aliases( `let p: fn() = tgt; p();` and `let q = tgt; q();` bind a callable to a variable; a later `p()`/`q()` is a real edge to `tgt` that bare-name - resolution misses (val_3_18). Only a RHS that is a *bare identifier + resolution misses. Only a RHS that is a *bare identifier naming a known FREE function* creates an alias -- never a call, closure, method, or arbitrary expression. The RHS is resolved through the SAME gate a bare call uses -- free functions only (a bare identifier in value @@ -577,7 +714,7 @@ def _collect_type_param_bounds(self, code: str) -> Dict[str, List[str]]: dispatch to the trait's conformers via the SAME `trait_impls` closure the `&dyn Trait` path uses -- nominal typing makes the conformer set knowable and bounded, so this is a reachability-safe over-approximation, not a - guess (val_3_19 extended from `dyn` to generic bounds; the Swift parser's + guess (extended from `dyn` to generic bounds; the Swift parser's protocol-conformer dispatch is the reference). """ bounds: Dict[str, List[str]] = {} @@ -653,7 +790,7 @@ def _resolve_bare( fn_aliases: Optional[Dict[str, List[str]]] = None, ) -> List[str]: # A local variable bound to a function value: `let p = tgt; p()` -> tgt - # (val_3_18). Checked before name resolution because `p` is not itself a + # Checked before name resolution because `p` is not itself a # function name; the alias only exists when RHS named a known function. if fn_aliases and call_name in fn_aliases: return fn_aliases[call_name] @@ -688,6 +825,13 @@ def _free(ids): same_file = [c for c in candidates if self._in_file(c, caller_file)] if same_file: return same_file + # A bare call to a name imported from an EXTERNAL crate (`use + # std::thread::spawn`) is that external symbol, not a cross-file repo + # function of the same name -- linking to the repo namesake is a + # wrong-target phantom. Same-file (checked above) still wins; a genuine + # repo import (`use crate::..`) has no external root and is unaffected. + if self._name_is_externally_imported(call_name, caller_file): + return [] if len(candidates) == 1: return candidates return [] @@ -699,6 +843,32 @@ def _resolve_via_use(self, imp: dict, name_to_ids: Dict[str, List[str]]) -> List candidates = name_to_ids.get(leaf, []) return candidates if len(candidates) == 1 else [] + # Crate roots that are unambiguously NOT part of the analyzed repo. A `use` + # rooted here binds an external symbol; `crate`/`super`/`self`-rooted paths + # (and bare local-mod paths) are repo-internal and never match. + _EXTERNAL_CRATE_ROOTS = frozenset({"std", "core", "alloc", "proc_macro", "test"}) + + def _name_is_externally_imported(self, call_name: str, caller_file: str) -> bool: + """True if `call_name` is brought into `caller_file` by a `use` rooted in a + known external crate (`std`/`core`/`alloc`/...) AND is not ALSO repo-imported + (`use crate::`/`self`/`super`) in the same file. Such a bare call is the + external symbol, so it must not resolve to a same-named repo function. A + coexisting repo import means some scope legitimately calls the repo function + -- imports are tracked per file, not per scope, so we can't tell which call + is which; decline to force-drop and let candidate resolution decide.""" + ext = repo = False + for imp in self.imports.get(caller_file, []): + if imp.get("kind") != "use": + continue + if (imp.get("alias") or imp.get("leaf")) != call_name: + continue + root = (imp.get("path") or "").split("::", 1)[0] + if root in self._EXTERNAL_CRATE_ROOTS: + ext = True + elif root in ("crate", "self", "super"): + repo = True + return ext and not repo + def _resolve_field( self, site: dict, caller_file: str, caller_class: Optional[str], name_to_ids: Dict[str, List[str]], var_types: Dict[str, str], @@ -740,7 +910,7 @@ def _resolve_field( # Generic trait-bound receiver (`item: &B` where `B: Shape`) # is resolved FIRST and never via a concrete-type lookup on the # letter: dispatch to the intersection of the bound traits' - # conformers (val_3_19 for generics). Checking bounds before the + # conformers (for generics). Checking bounds before the # concrete lookup is what stops a blanket `impl Y for T` # (which mints a pseudo-type `"T"`) from hijacking every generic # receiver named `T`/`B` repo-wide. @@ -784,7 +954,15 @@ def _resolve_generic_bound_member( if not known: return [] conformer_sets = [set(self.trait_impls.get(t, [])) for t in known] - conformers = set.intersection(*conformer_sets) if len(conformer_sets) > 1 else conformer_sets[0] + # A bound whose conformer set is EMPTY is *unconstrained*, not impossible: + # marker/blanket/derive/cross-crate impls are invisible to the extractor + # (blanket pairs are deliberately skipped in function_extractor), so an empty + # set means "conformers unknown", and must NOT annihilate the edges the other + # bounds legitimately establish (`B: Shape + Marker` still reaches Shape's + # conformers). Intersect only over bounds that actually constrain — those with + # >=1 known conformer. Reachability over-approximation, not a guess. + constraining = [s for s in conformer_sets if s] + conformers = set.intersection(*constraining) if constraining else set() allowed = conformers | set(known) # concrete conformers + trait-default bodies result: List[str] = [] for cand in name_to_ids.get(method, []): @@ -815,7 +993,7 @@ def _resolve_unknown_receiver_method( if not candidates: return [] same_file = [c for c in candidates if self._in_file(c, caller_file)] - # F2/F3/F4: only emit an edge when the receiver-less resolution is + # Unknown-receiver gate: only emit an edge when the receiver-less resolution is # UNAMBIGUOUS. When several same-named `&self` methods live in the same # file (e.g. Dog::speak and Cat::speak) and the receiver's static type is # unknown (inferred from a fn-return, struct field, `dyn Trait`, or a @@ -828,6 +1006,15 @@ def _resolve_unknown_receiver_method( return same_file if len(candidates) == 1: return candidates + # Known limitation (deliberate, not a bug): when >=2 same-named `&self` + # methods remain and the receiver type is unknown, we decline. Exported + # candidates self-seed so declining them is free; a PRIVATE inherent method + # whose sole edge is declined can black out. That blackout is RECOVERED + # upstream wherever the receiver type is inferable -- e.g. `let c = load()` + # with `load() -> Cfg` types the receiver (see _free_fn_return_type), so the + # call never reaches this gate. A blanket union of the residual truly-ambiguous + # case would add a cross-type phantom (Dog->Cat.speak) per call site, so it is + # not done here. return [] def _resolve_typed_member( diff --git a/libs/openant-core/parsers/rust/function_extractor.py b/libs/openant-core/parsers/rust/function_extractor.py index 35315d2..463030e 100644 --- a/libs/openant-core/parsers/rust/function_extractor.py +++ b/libs/openant-core/parsers/rust/function_extractor.py @@ -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 @@ -75,6 +75,17 @@ def _load_rust_language() -> Language: # `impl` block, or as the RHS of a `let x: = ...` 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. @@ -106,7 +117,7 @@ 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) @@ -114,8 +125,8 @@ def _bare_type_name(node: Optional[Node], source: bytes) -> Optional[str]: 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: @@ -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 Foo` and `impl Foo 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 "" @@ -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 @@ -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: @@ -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)) @@ -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, @@ -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 Foo`), + # 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 diff --git a/libs/openant-core/parsers/rust/unit_generator.py b/libs/openant-core/parsers/rust/unit_generator.py index 82e4007..1293705 100644 --- a/libs/openant-core/parsers/rust/unit_generator.py +++ b/libs/openant-core/parsers/rust/unit_generator.py @@ -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", []), }, } diff --git a/libs/openant-core/tests/parsers/rust/_helpers.py b/libs/openant-core/tests/parsers/rust/_rust_helpers.py similarity index 100% rename from libs/openant-core/tests/parsers/rust/_helpers.py rename to libs/openant-core/tests/parsers/rust/_rust_helpers.py diff --git a/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py new file mode 100644 index 0000000..d335512 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_assoc_return_type.py @@ -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]) diff --git a/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py new file mode 100644 index 0000000..ba55c4f --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_bound_intersection.py @@ -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 Marker for X {} + pub fn total(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 diff --git a/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py new file mode 100644 index 0000000..7d0ec7d --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_builtin_filter.py @@ -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 diff --git a/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py b/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py index 2eb2edd..0a428d2 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_callgraph_symmetry.py @@ -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": """ diff --git a/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py new file mode 100644 index 0000000..152aac6 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_freefn_return_type.py @@ -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 diff --git a/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py b/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py index aacf2ac..e1fd1d3 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_generic_dispatch.py @@ -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): diff --git a/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py new file mode 100644 index 0000000..e4f78ed --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_impl_level_bounds.py @@ -0,0 +1,24 @@ +"""a method receiver typed as an IMPL-level generic param (`impl +Holder { 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 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 Audit for U { fn area(&self) {} } + pub struct Holder { x: T } + impl Holder { 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 diff --git a/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py new file mode 100644 index 0000000..4fc6e3b --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_macro_string_literal.py @@ -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 diff --git a/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py new file mode 100644 index 0000000..98a6036 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_method_collision.py @@ -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] diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py new file mode 100644 index 0000000..93ad8d1 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_nested_fn_scope.py @@ -0,0 +1,19 @@ +"""a nested fn's call sites must be attributed to the nested unit, not bled +into the OUTER fn (and resolved under the outer fn's — wrong — generic bounds). +`fn outer(){ fn inner(l:&B){ l.log() } ... }` must not create +`outer -> Circle.log` (Circle is Shape, not Logger). The inner unit keeps its edge.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _rust_helpers import build, edges # noqa: E402 + + +def test_nested_fn_calls_not_bled_into_outer(tmp_path): + repo = {"lib.rs": """ + pub trait Shape { fn area(&self); } + pub trait Logger { fn log(&self); } + pub struct Circle; impl Shape for Circle { fn area(&self){} } impl Circle { fn log(&self){} } + pub fn outer(s: &B) { fn inner(l: &B) { l.log(); } s.area(); } + """} + e = edges(build(tmp_path, repo)[1]) + assert ("outer", "Circle.area") in e, e # outer's real call survives + assert ("outer", "Circle.log") not in e, e # nested inner's call must NOT bleed to outer diff --git a/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py new file mode 100644 index 0000000..59a519e --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_nonnominal_impl.py @@ -0,0 +1,18 @@ +"""trait/inherent impls on non-nominal Self types (primitive/array/tuple/ +unit) must still have their methods extracted. `_bare_type_name` names only nominal +types, so `impl Serialize for u32` was dropped entirely (every method lost).""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _rust_helpers import extract # noqa: E402 + + +def test_nonnominal_self_impls_extracted(tmp_path): + ext = extract(tmp_path, {"a.rs": """ + pub trait Enc { fn enc(&self); } + impl Enc for u32 { fn enc(&self) {} } + impl Enc for [u8; 4] { fn enc(&self) {} } + impl Enc for (i32, i32) { fn enc(&self) {} } + pub struct Ok1; impl Enc for Ok1 { fn enc(&self) {} } + """}) + encs = [f for f in ext["functions"].values() if f["name"] == "enc"] + assert len(encs) == 4, sorted(f["class_name"] for f in encs) diff --git a/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py b/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py new file mode 100644 index 0000000..f989762 --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_returntype_ambiguity.py @@ -0,0 +1,74 @@ +"""Regressions from return-type inference: it must DECLINE (not guess) when the +producer is ambiguous or locally shadowed, otherwise it fabricates a wrong-type edge.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _rust_helpers import build, edges # noqa: E402 + + +def test_assoc_return_ambiguous_duplicate_type_declines(tmp_path): + # Two types both named Factory with different-return make(): don't guess one. + repo = { + "f1.rs": "pub struct Widget; impl Widget { pub fn spin(&self) {} }\n" + "pub struct Factory; impl Factory { pub fn make() -> Widget { Widget } }\n", + "f2.rs": "pub struct Gear; impl Gear { pub fn spin(&self) {} }\n" + "pub struct Factory; impl Factory { pub fn make() -> Gear { Gear } }\n", + "main.rs": "use crate::f2::Factory;\npub fn caller() { let x = Factory::make(); x.spin(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("caller", "Widget.spin") not in e, e # must NOT guess f1's Widget + assert ("caller", "Gear.spin") not in e, e # can't know it's f2's Gear either -> decline both + + +def test_free_fn_return_local_closure_shadow_declines(tmp_path): + repo = { + "cfg.rs": "pub struct Cfg; impl Cfg { pub fn spin(&self) {} }\npub fn helper() -> Cfg { Cfg }\n", + "w.rs": "pub struct Widget; impl Widget { pub fn new() -> Widget { Widget } pub fn spin(&self) {} }\n", + "main.rs": "use crate::w::Widget;\npub fn caller() { let helper = || Widget::new(); let x = helper(); x.spin(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("caller", "Cfg.spin") not in e, e # helper is a local closure, NOT the free fn + + +def test_std_imported_name_does_not_link_to_repo_namesake(tmp_path): + # `use std::thread::spawn` makes bare `spawn()` a std call, NOT the repo `fn spawn`. + repo = { + "workers.rs": "pub fn spawn(n: i32) -> i32 { n }\n", + "main.rs": "use std::thread::spawn;\npub fn run_thread() { let h = spawn(); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run_thread", "spawn") not in e, e + + +def test_crate_imported_name_still_links_to_repo_fn(tmp_path): + # Control: a genuine repo import must STILL resolve (no over-decline / recall loss). + repo = { + "workers.rs": "pub fn spawn(n: i32) -> i32 { n }\n", + "main.rs": "use crate::workers::spawn;\npub fn run_thread() { let h = spawn(1); }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("run_thread", "spawn") in e, e + + +def test_free_fn_return_survives_noncallable_name_reuse(tmp_path): + # `let load = 5` reuses the name but is not callable -- must NOT block typing + # `let c = load()` from the free fn. Decoy Other::method forces receiver typing. + repo = { + "lib.rs": "pub struct Cfg; impl Cfg { pub fn method(&self) {} }\n" + "pub struct Other; impl Other { pub fn method(&self) {} }\n" + "pub fn load() -> Cfg { Cfg }\n", + "caller.rs": "fn go() { let c = load(); c.method(); let load = 5; let _ = load + 1; }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("go", "Cfg.method") in e, e + + +def test_repo_import_survives_coexisting_std_import_other_scope(tmp_path): + # mod b legitimately `use crate::worker::take`; mod a's std import must not + # poison it (imports are file-scoped). + repo = { + "worker.rs": "pub fn take() -> i32 { 0 }\n", + "caller.rs": "mod a { use std::mem::take; pub fn f() { let mut v = vec![1]; let _ = take(&mut v); } }\n" + "mod b { use crate::worker::take; pub fn g() { take(); } }\n", + } + e = edges(build(tmp_path, repo)[1]) + assert ("b::g", "take") in e, e diff --git a/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py b/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py index 68db14f..f3f7294 100644 --- a/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py +++ b/libs/openant-core/tests/parsers/rust/test_rust_schema_completeness.py @@ -10,7 +10,7 @@ import sys sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -from _helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 +from _rust_helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 _SRC = { "widget.rs": """ diff --git a/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py new file mode 100644 index 0000000..805bd8d --- /dev/null +++ b/libs/openant-core/tests/parsers/rust/test_rust_unit_decorators.py @@ -0,0 +1,18 @@ +"""Rust unit metadata must carry `decorators` (parity with the Swift parser), +so dataset units expose the function's attributes like every sibling parser.""" +import pathlib, sys +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from _rust_helpers import extract, CallGraphBuilder, UnitGenerator # noqa: E402 + + +def test_unit_metadata_carries_decorators(tmp_path): + ext = extract(tmp_path, {"lib.rs": "#[inline]\npub fn f() {}\n"}) + cg = CallGraphBuilder(ext).build() + dataset, _ = UnitGenerator(cg, str(tmp_path)).generate() + units = dataset["units"] + assert units, "no units generated" + for u in units: + assert "decorators" in u["metadata"], u["metadata"].keys() + # the #[inline] attribute must survive into unit metadata (not just call_graph) + assert any(u["metadata"]["decorators"] == ["inline"] for u in units), \ + [u["metadata"]["decorators"] for u in units] diff --git a/libs/openant-core/tests/test_scanner_contract.py b/libs/openant-core/tests/test_scanner_contract.py index cc744a5..69a1746 100644 --- a/libs/openant-core/tests/test_scanner_contract.py +++ b/libs/openant-core/tests/test_scanner_contract.py @@ -165,7 +165,7 @@ def test_deeply_nested_code_is_scanned_or_recorded_as_a_gap(language, tmp_path): repo.mkdir() ext = {"python": ".py", "c": ".c", "php": ".php", "ruby": ".rb", "zig": ".zig", "javascript": ".js", "go": ".go", - "swift": ".swift"}.get(language, ".py") + "swift": ".swift", "rust": ".rs"}.get(language, ".py") build_deep_nest(repo / "deep", 600, f"planted{ext}", "x = 1\n") try: