1use std::path::{Path, PathBuf};
31
32use fallow_types::extract::{ImportedName, ModuleInfo};
33pub use fallow_types::trace_chain::{
34 ChainHop, DEFAULT_TRACE_DEPTH, SymbolChainQuery, SymbolChainTrace, TraceDirections,
35 UnresolvedCallee, UnresolvedReason,
36};
37use rustc_hash::{FxHashMap, FxHashSet};
38
39use crate::discover::FileId;
40use crate::graph::ModuleGraph;
41
42#[must_use]
48pub fn trace_symbol_chain(
49 graph: &ModuleGraph,
50 modules: &[ModuleInfo],
51 root: &Path,
52 query: SymbolChainQuery<'_>,
53) -> Option<SymbolChainTrace> {
54 let SymbolChainQuery {
55 file,
56 symbol,
57 depth,
58 directions,
59 } = query;
60 let module = graph
61 .modules
62 .iter()
63 .find(|m| path_matches(&m.path, root, file))?;
64 let rel_file = relativize(&module.path, root);
65
66 let symbol_found = module.exports.iter().any(|e| e.name.to_string() == *symbol);
67
68 let module_by_id: FxHashMap<FileId, &ModuleInfo> =
69 modules.iter().map(|m| (m.file_id, m)).collect();
70
71 let callers = directions
72 .callers
73 .then(|| collect_callers(graph, root, module.file_id, symbol, depth));
74 let callees_walk = directions
75 .callees
76 .then(|| collect_callees(graph, &module_by_id, root, module.file_id, symbol, depth));
77 let (callees, unresolved_callees) = match callees_walk {
78 Some((resolved, unresolved)) => (Some(resolved), Some(unresolved)),
79 None => (None, None),
80 };
81
82 let reason = build_reason(
83 symbol_found,
84 callers.as_deref(),
85 callees.as_deref(),
86 unresolved_callees.as_deref(),
87 );
88
89 Some(SymbolChainTrace {
90 file: rel_file,
91 symbol: symbol.to_string(),
92 symbol_found,
93 depth,
94 best_effort: true,
95 callers,
96 callees,
97 unresolved_callees,
98 reason,
99 })
100}
101
102struct WalkCtx<'a> {
104 graph: &'a ModuleGraph,
105 root: &'a Path,
106 max_depth: u32,
107}
108
109fn collect_callers(
112 graph: &ModuleGraph,
113 root: &Path,
114 target: FileId,
115 symbol: &str,
116 depth: u32,
117) -> Vec<ChainHop> {
118 let ctx = WalkCtx {
119 graph,
120 root,
121 max_depth: depth,
122 };
123 let mut hops = Vec::new();
124 let mut visited: FxHashSet<(FileId, String)> = FxHashSet::default();
125 walk_callers_recursive(&ctx, target, symbol, 1, &mut visited, &mut hops);
126 hops.sort_by(|a, b| {
127 a.depth
128 .cmp(&b.depth)
129 .then_with(|| a.file.cmp(&b.file))
130 .then_with(|| a.local_name.cmp(&b.local_name))
131 });
132 hops
133}
134
135fn walk_callers_recursive(
136 ctx: &WalkCtx<'_>,
137 target: FileId,
138 symbol: &str,
139 current_depth: u32,
140 visited: &mut FxHashSet<(FileId, String)>,
141 hops: &mut Vec<ChainHop>,
142) {
143 if current_depth > ctx.max_depth {
144 return;
145 }
146 for &importer in ctx.graph.importers_of(target) {
147 for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(importer) {
148 if edge_target != target {
149 continue;
150 }
151 for sym in symbols {
152 if !imported_name_matches(&sym.imported_name, symbol) {
153 continue;
154 }
155 let key = (importer, sym.local_name.clone());
156 if !visited.insert(key) {
157 continue;
158 }
159 let importer_path = ctx.graph.modules.get(importer.0 as usize).map_or_else(
160 || PathBuf::from("<unknown>"),
161 |m| relativize(&m.path, ctx.root),
162 );
163 hops.push(ChainHop {
164 file: importer_path,
165 imported_as: imported_name_label(&sym.imported_name),
166 local_name: sym.local_name.clone(),
167 type_only: sym.is_type_only,
168 depth: current_depth,
169 });
170 walk_callers_recursive(
173 ctx,
174 importer,
175 &sym.local_name,
176 current_depth + 1,
177 visited,
178 hops,
179 );
180 }
181 }
182 }
183}
184
185fn collect_callees(
189 graph: &ModuleGraph,
190 module_by_id: &FxHashMap<FileId, &ModuleInfo>,
191 root: &Path,
192 module_id: FileId,
193 _symbol: &str,
194 depth: u32,
195) -> (Vec<ChainHop>, Vec<UnresolvedCallee>) {
196 let ctx = WalkCtx {
197 graph,
198 root,
199 max_depth: depth,
200 };
201 let mut resolved = Vec::new();
202 let mut visited: FxHashSet<FileId> = FxHashSet::default();
203 visited.insert(module_id);
204 walk_callees_recursive(&ctx, module_id, 1, &mut visited, &mut resolved);
205 resolved.sort_by(|a, b| {
206 a.depth
207 .cmp(&b.depth)
208 .then_with(|| a.file.cmp(&b.file))
209 .then_with(|| a.local_name.cmp(&b.local_name))
210 });
211
212 let unresolved = module_by_id
213 .get(&module_id)
214 .map(|info| collect_unresolved_callees(info))
215 .unwrap_or_default();
216
217 (resolved, unresolved)
218}
219
220fn walk_callees_recursive(
221 ctx: &WalkCtx<'_>,
222 module_id: FileId,
223 current_depth: u32,
224 visited: &mut FxHashSet<FileId>,
225 hops: &mut Vec<ChainHop>,
226) {
227 if current_depth > ctx.max_depth {
228 return;
229 }
230 for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(module_id) {
231 let target_path = ctx.graph.modules.get(edge_target.0 as usize).map_or_else(
232 || PathBuf::from("<unknown>"),
233 |m| relativize(&m.path, ctx.root),
234 );
235 for sym in symbols {
236 if matches!(sym.imported_name, ImportedName::SideEffect) {
238 continue;
239 }
240 hops.push(ChainHop {
241 file: target_path.clone(),
242 imported_as: imported_name_label(&sym.imported_name),
243 local_name: sym.local_name.clone(),
244 type_only: sym.is_type_only,
245 depth: current_depth,
246 });
247 }
248 if visited.insert(edge_target) {
249 walk_callees_recursive(ctx, edge_target, current_depth + 1, visited, hops);
250 }
251 }
252}
253
254fn collect_unresolved_callees(info: &ModuleInfo) -> Vec<UnresolvedCallee> {
259 let import_locals: FxHashSet<&str> =
260 info.imports.iter().map(|i| i.local_name.as_str()).collect();
261
262 let mut out = Vec::new();
263 let mut seen: FxHashSet<&str> = FxHashSet::default();
264 for callee in &info.callee_uses {
265 let path = callee.callee_path.as_str();
266 let leading = path.split('.').next().unwrap_or(path);
267 if import_locals.contains(leading) {
270 continue;
271 }
272 if !seen.insert(path) {
273 continue;
274 }
275 let reason = if path.contains('.') {
276 UnresolvedReason::MemberOrDynamic
277 } else {
278 UnresolvedReason::LocalOrGlobal
279 };
280 out.push(UnresolvedCallee {
281 callee: path.to_string(),
282 reason,
283 });
284 }
285 out.sort_by(|a, b| a.callee.cmp(&b.callee));
286 out
287}
288
289fn build_reason(
290 symbol_found: bool,
291 callers: Option<&[ChainHop]>,
292 callees: Option<&[ChainHop]>,
293 unresolved: Option<&[UnresolvedCallee]>,
294) -> String {
295 if !symbol_found {
296 return "symbol not found as an export of this file; chains are file-scoped best-effort and may be empty".to_string();
297 }
298 let mut parts = Vec::new();
299 if let Some(callers) = callers {
300 parts.push(format!("{} caller hop(s)", callers.len()));
301 }
302 if let Some(callees) = callees {
303 parts.push(format!("{} resolved callee hop(s)", callees.len()));
304 }
305 if let Some(unresolved) = unresolved {
306 parts.push(format!(
307 "{} unresolved callee(s) reported",
308 unresolved.len()
309 ));
310 }
311 format!(
312 "best-effort syntactic chain (ADR-001): {}",
313 parts.join(", ")
314 )
315}
316
317fn imported_name_matches(name: &ImportedName, symbol: &str) -> bool {
320 match name {
321 ImportedName::Named(n) => n == symbol,
322 ImportedName::Default => symbol == "default",
323 ImportedName::Namespace => true,
324 ImportedName::SideEffect => false,
325 }
326}
327
328fn imported_name_label(name: &ImportedName) -> String {
329 match name {
330 ImportedName::Named(name) => name.clone(),
331 ImportedName::Default => "default".to_string(),
332 ImportedName::Namespace => "*".to_string(),
333 ImportedName::SideEffect => "side-effect".to_string(),
334 }
335}
336
337fn relativize(path: &Path, root: &Path) -> PathBuf {
338 path.strip_prefix(root).unwrap_or(path).to_path_buf()
339}
340
341fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
344 let user_path_norm = user_path.replace('\\', "/");
345 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
346 let rel_str = rel.to_string_lossy().replace('\\', "/");
347 let module_str = module_path.to_string_lossy().replace('\\', "/");
348 if rel_str == user_path_norm || module_str == user_path_norm {
349 return true;
350 }
351 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
352 module_path
353 .strip_prefix(&canonical_root)
354 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
355 }) {
356 return true;
357 }
358 module_str.ends_with(&format!("/{user_path_norm}"))
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::analyze::test_support::empty_module;
365
366 #[test]
367 fn imported_name_matches_named_default_namespace() {
368 assert!(imported_name_matches(
369 &ImportedName::Named("foo".to_string()),
370 "foo"
371 ));
372 assert!(!imported_name_matches(
373 &ImportedName::Named("bar".to_string()),
374 "foo"
375 ));
376 assert!(imported_name_matches(&ImportedName::Default, "default"));
377 assert!(!imported_name_matches(&ImportedName::Default, "foo"));
378 assert!(imported_name_matches(&ImportedName::Namespace, "anything"));
380 assert!(!imported_name_matches(&ImportedName::SideEffect, "foo"));
381 }
382
383 #[test]
384 fn unresolved_reason_classifies_member_vs_bare() {
385 let info = ModuleInfo {
386 callee_uses: vec![
387 fallow_types::extract::CalleeUse {
388 callee_path: "localHelper".to_string(),
389 span_start: 0,
390 },
391 fallow_types::extract::CalleeUse {
392 callee_path: "obj.method".to_string(),
393 span_start: 10,
394 },
395 ],
396 ..empty_module()
397 };
398 let unresolved = collect_unresolved_callees(&info);
399 assert_eq!(unresolved.len(), 2);
400 assert_eq!(unresolved[0].callee, "localHelper");
401 assert_eq!(unresolved[0].reason, UnresolvedReason::LocalOrGlobal);
402 assert_eq!(unresolved[1].callee, "obj.method");
403 assert_eq!(unresolved[1].reason, UnresolvedReason::MemberOrDynamic);
404 }
405
406 #[test]
407 fn imported_callees_are_not_listed_as_unresolved() {
408 let info = ModuleInfo {
409 imports: vec![fallow_types::extract::ImportInfo {
410 source: "./dep".to_string(),
411 imported_name: ImportedName::Named("dep".to_string()),
412 local_name: "dep".to_string(),
413 is_type_only: false,
414 from_style: false,
415 span: oxc_span::Span::default(),
416 source_span: oxc_span::Span::default(),
417 }],
418 callee_uses: vec![
419 fallow_types::extract::CalleeUse {
420 callee_path: "dep".to_string(),
421 span_start: 0,
422 },
423 fallow_types::extract::CalleeUse {
424 callee_path: "ghost".to_string(),
425 span_start: 5,
426 },
427 ],
428 ..empty_module()
429 };
430 let unresolved = collect_unresolved_callees(&info);
431 assert_eq!(unresolved.len(), 1);
434 assert_eq!(unresolved[0].callee, "ghost");
435 }
436}