ryo_executor/engine/impls/inline_trait_caller_scanner.rs
1//! CrossCrate InlineTrait Phase 2c — caller crate scanner.
2//!
3//! Given a target trait `SymbolId`, walks the workspace's typeflow
4//! graph for containers (functions / impl blocks / etc.) that
5//! reference the trait, filters down to **cross-crate** callers (i.e.
6//! containers living in a different crate from the trait), and
7//! classifies each caller via the `classify_fn` helper from
8//! `ryo-mutations` (Phase 2a+2b).
9//!
10//! Each cross-crate caller produces a [`CallerRewrite`] entry listing
11//! the `CallerPattern` variants the executor must apply during the
12//! caller-side AST migration in Phase 2d:
13//!
14//! - `Dot` — post-inline dispatch is unchanged; no AST edit.
15//! - `Ufcs` — rewrite `<S as Trait>::m(..)` → `S::m(..)`.
16//! - `GenericBound`— strip `T: Trait` generic param and specialize.
17//! - `DynDispatch` — rewrite `&dyn Trait` → `&S`.
18//! - `Other` — block inline (conservatively).
19//!
20//! Phase 2c stops at the **scan + classify** boundary. The executor
21//! does not rewrite caller crates yet; that engine arrives in Phase
22//! 2d, consuming the [`CallerRewrite`] entries produced here.
23
24use ryo_analysis::context::AnalysisContext;
25use ryo_analysis::query::CodeGraphV2;
26use ryo_analysis::ASTRegistry;
27use ryo_mutations::basic::trait_ops::cross_crate_caller_pattern::{classify_fn, CallerPattern};
28use ryo_source::pure::{PureItem, PureTraitItem, PureVis};
29use ryo_symbol::{SymbolId, SymbolKind, SymbolRegistry};
30use std::collections::{HashSet, VecDeque};
31
32/// Per-caller rewrite metadata returned by [`scan_cross_crate_callers`].
33#[derive(Debug, Clone)]
34pub struct CallerRewrite {
35 /// Caller container symbol (typically a `PureFn`).
36 pub caller_id: SymbolId,
37 /// Patterns detected in this caller; the executor's Phase 2d
38 /// engine will run one rewrite path per entry.
39 pub patterns: Vec<CallerPattern>,
40 /// Phase 4-A: whether the caller is `pub` (visible outside its
41 /// own crate). True only for `PureVis::Public`; `pub(crate)`,
42 /// `pub(super)`, `pub(in path)`, and private all map to false.
43 ///
44 /// When this is true AND the caller carries `GenericBound`, the
45 /// Phase 2g rewriter changes the caller's external API contract.
46 /// Downstream call sites in unrelated crates would have to be
47 /// updated. Phase 4-A surfaces this as a warning footnote on the
48 /// `MutationResult.description`; Phase 4-B will add the transitive
49 /// walk that decides whether the cascade actually leaves the
50 /// current workspace.
51 pub is_public: bool,
52}
53
54/// Scan every cross-crate caller of `trait_id` and classify each by
55/// caller-side rewrite pattern.
56///
57/// "Cross-crate" means the caller's path resolves to a different
58/// crate from the trait. Same-crate callers are intentionally
59/// ignored — the existing single-crate executor already handles
60/// those (or the v3.1 EXTERNAL_USE filter wholesale-skips the
61/// suggest, depending on the call site).
62///
63/// Returns an empty `Vec` when:
64/// - `trait_id` is not in the registry or has no name,
65/// - no cross-crate container references the trait,
66/// - every cross-crate caller's AST is missing or is not a `PureFn`
67/// (Phase 2c covers only function callers; struct / impl callers
68/// arrive in Phase 2d alongside the rewrite engine).
69///
70/// Takes a `SymbolRegistry` + `ASTRegistry` directly so it can be
71/// invoked from either an `AnalysisContext` (full pipeline) or an
72/// `ASTMutationContext` (executor `apply_to_registry` entry point),
73/// without depending on `detail_store` / `typeflow_graph` (which
74/// `ASTMutationContext` does not carry).
75pub fn scan_cross_crate_callers_raw(
76 ast_registry: &ASTRegistry,
77 symbol_registry: &SymbolRegistry,
78 trait_id: SymbolId,
79) -> Vec<CallerRewrite> {
80 let trait_crate = match symbol_registry.path(trait_id) {
81 Some(p) => p.crate_name().to_string(),
82 None => return Vec::new(),
83 };
84 let trait_name = match symbol_registry.path(trait_id) {
85 Some(p) => p.name().to_string(),
86 None => return Vec::new(),
87 };
88 if trait_crate.is_empty() || trait_name.is_empty() {
89 return Vec::new();
90 }
91
92 // Pull trait method names directly off the `PureTrait` AST so this
93 // helper does not need a `DetailStore` (which `ASTMutationContext`
94 // does not carry).
95 let trait_methods: Vec<String> = match ast_registry.get(trait_id) {
96 Some(PureItem::Trait(t)) => t
97 .items
98 .iter()
99 .filter_map(|it| match it {
100 PureTraitItem::Fn(f) => Some(f.name.clone()),
101 _ => None,
102 })
103 .collect(),
104 _ => Vec::new(),
105 };
106
107 // Brute-force scan: iterate every Function symbol in the registry,
108 // keep only those whose crate differs from the trait's crate, then
109 // classify via `classify_fn` against the trait name/methods.
110 //
111 // Rationale (Phase 0 caveat, RL060 epic): typeflow.type_users drops
112 // the container for `WhereBound` / `ParamType` / `ReturnType`
113 // usages, which is exactly where cross-crate callers live (generic
114 // bound + `&dyn Trait` parameter + return type). Brute-force
115 // registry iteration sidesteps the typeflow gap.
116 let mut out = Vec::new();
117 let fn_ids: Vec<SymbolId> = symbol_registry
118 .iter()
119 .filter(|(id, path)| {
120 matches!(symbol_registry.kind(*id), Some(SymbolKind::Function))
121 && path.crate_name() != trait_crate
122 })
123 .map(|(id, _)| id)
124 .collect();
125
126 for caller_id in fn_ids {
127 if let Some(PureItem::Fn(f)) = ast_registry.get(caller_id) {
128 let patterns = classify_fn(f, &trait_name, &trait_methods);
129 if !patterns.is_empty() {
130 let is_public = matches!(f.vis, PureVis::Public);
131 out.push(CallerRewrite {
132 caller_id,
133 patterns,
134 is_public,
135 });
136 }
137 }
138 }
139 out
140}
141
142/// Convenience wrapper that reads the `ASTRegistry` and
143/// `SymbolRegistry` off an `AnalysisContext`. Used by the integration
144/// pin and by any caller that already holds a full analysis context.
145pub fn scan_cross_crate_callers(ctx: &AnalysisContext, trait_id: SymbolId) -> Vec<CallerRewrite> {
146 scan_cross_crate_callers_raw(&ctx.ast_registry, &ctx.registry, trait_id)
147}
148
149/// Phase 5-B: symmetric counterpart to [`scan_cross_crate_callers_raw`]
150/// that returns the callers living in the **same** crate as the trait
151/// (excluding the implementor's own impl block and any of its method
152/// children). Used by the suggester to detect the mixed-handling case
153/// where same-crate external use coexists with cross-crate callers —
154/// in that situation the Phase 3 gate must fall through to the v3.1
155/// EXTERNAL_USE filter chain rather than bypassing it.
156///
157/// `internal_ids` is the set of symbols that should NOT count as
158/// external callers (typically `impl_id` plus the `children_of`
159/// the impl block). The Phase 3 gate computes this set once and
160/// passes it to both this helper and the existing typeflow walk.
161pub fn scan_same_crate_external_callers_raw(
162 ast_registry: &ASTRegistry,
163 symbol_registry: &SymbolRegistry,
164 trait_id: SymbolId,
165 internal_ids: &HashSet<SymbolId>,
166) -> Vec<CallerRewrite> {
167 let trait_crate = match symbol_registry.path(trait_id) {
168 Some(p) => p.crate_name().to_string(),
169 None => return Vec::new(),
170 };
171 let trait_name = match symbol_registry.path(trait_id) {
172 Some(p) => p.name().to_string(),
173 None => return Vec::new(),
174 };
175 if trait_crate.is_empty() || trait_name.is_empty() {
176 return Vec::new();
177 }
178
179 let trait_methods: Vec<String> = match ast_registry.get(trait_id) {
180 Some(PureItem::Trait(t)) => t
181 .items
182 .iter()
183 .filter_map(|it| match it {
184 PureTraitItem::Fn(f) => Some(f.name.clone()),
185 _ => None,
186 })
187 .collect(),
188 _ => Vec::new(),
189 };
190
191 let mut out = Vec::new();
192 let fn_ids: Vec<SymbolId> = symbol_registry
193 .iter()
194 .filter(|(id, path)| {
195 matches!(symbol_registry.kind(*id), Some(SymbolKind::Function))
196 && path.crate_name() == trait_crate
197 && !internal_ids.contains(id)
198 })
199 .map(|(id, _)| id)
200 .collect();
201
202 for caller_id in fn_ids {
203 if let Some(PureItem::Fn(f)) = ast_registry.get(caller_id) {
204 let patterns = classify_fn(f, &trait_name, &trait_methods);
205 if !patterns.is_empty() {
206 let is_public = matches!(f.vis, PureVis::Public);
207 out.push(CallerRewrite {
208 caller_id,
209 patterns,
210 is_public,
211 });
212 }
213 }
214 }
215 out
216}
217
218/// Convenience wrapper for [`scan_same_crate_external_callers_raw`]
219/// that takes an `AnalysisContext`.
220pub fn scan_same_crate_external_callers(
221 ctx: &AnalysisContext,
222 trait_id: SymbolId,
223 internal_ids: &HashSet<SymbolId>,
224) -> Vec<CallerRewrite> {
225 scan_same_crate_external_callers_raw(&ctx.ast_registry, &ctx.registry, trait_id, internal_ids)
226}
227
228// ============================================================================
229// Phase 4-B — transitive caller cascade verdict
230// ============================================================================
231
232/// Result of [`walk_transitive_callers`] — whether the cascade from a
233/// pub GenericBound caller can be safely rewritten given the in-
234/// workspace call graph.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum CascadeVerdict {
237 /// Every transitive caller in the workspace eventually terminates
238 /// at a non-pub leaf (or has no callers and is itself non-pub).
239 /// The GenericBound rewrite can safely commit.
240 Safe,
241 /// At least one transitive caller is a pub fn whose own callers
242 /// are not visible in the workspace registry — i.e. an external
243 /// crate may reach it. The GenericBound rewrite must abort for
244 /// that caller chain to avoid silently breaking the downstream
245 /// API contract.
246 Escapes,
247}
248
249/// BFS over `code_graph.callers_of` starting at `root_caller_id` to
250/// decide whether the transitive caller cascade ever surfaces a pub
251/// API leaf with no in-workspace caller.
252///
253/// Definition: a *leaf* is a symbol whose own callers_of iterator
254/// returns nothing. If the leaf's AST visibility is `PureVis::Public`,
255/// that means an external crate could call into the cascade and would
256/// be broken by the GenericBound rewrite — verdict is `Escapes`.
257/// Otherwise the leaf is private (or non-`Public` like `pub(crate)` /
258/// `pub(super)`) and the cascade stays inside the workspace — verdict
259/// is `Safe` for that branch. The walk continues until either an
260/// `Escapes` is found (early-return) or the queue empties (`Safe`).
261///
262/// Visited set guards against cycles. The root itself is included in
263/// the leaf check, so a pub fn with zero callers immediately returns
264/// `Escapes`.
265pub fn walk_transitive_callers(
266 code_graph: &CodeGraphV2,
267 ast_registry: &ASTRegistry,
268 root_caller_id: SymbolId,
269) -> CascadeVerdict {
270 let mut visited: HashSet<SymbolId> = HashSet::new();
271 let mut queue: VecDeque<SymbolId> = VecDeque::new();
272 queue.push_back(root_caller_id);
273 visited.insert(root_caller_id);
274
275 while let Some(id) = queue.pop_front() {
276 let callers: Vec<SymbolId> = code_graph.callers_of(id).collect();
277
278 if callers.is_empty() {
279 // Leaf: if its AST is a `pub fn`, it may be reached from
280 // an external crate. Otherwise the cascade is workspace-
281 // internal for this branch.
282 if let Some(PureItem::Fn(f)) = ast_registry.get(id) {
283 if matches!(f.vis, PureVis::Public) {
284 return CascadeVerdict::Escapes;
285 }
286 }
287 continue;
288 }
289
290 for caller in callers {
291 if visited.insert(caller) {
292 queue.push_back(caller);
293 }
294 }
295 }
296
297 CascadeVerdict::Safe
298}