1use std::path::{Path, PathBuf};
31
32use fallow_types::extract::{ImportedName, ModuleInfo};
33use fallow_types::serde_path;
34use rustc_hash::{FxHashMap, FxHashSet};
35use serde::Serialize;
36
37use crate::discover::FileId;
38use crate::graph::ModuleGraph;
39
40pub const DEFAULT_TRACE_DEPTH: u32 = 2;
43
44#[derive(Debug, Clone, Copy)]
46pub struct TraceDirections {
47 pub callers: bool,
49 pub callees: bool,
51}
52
53#[derive(Debug, Clone, Serialize)]
56#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
57pub struct SymbolChainTrace {
58 #[serde(serialize_with = "serde_path::serialize")]
60 pub file: PathBuf,
61 pub symbol: String,
63 pub symbol_found: bool,
66 pub depth: u32,
68 pub best_effort: bool,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub callers: Option<Vec<ChainHop>>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub callees: Option<Vec<ChainHop>>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub unresolved_callees: Option<Vec<UnresolvedCallee>>,
84 pub reason: String,
86}
87
88#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91pub struct ChainHop {
92 #[serde(serialize_with = "serde_path::serialize")]
95 pub file: PathBuf,
96 pub imported_as: String,
99 pub local_name: String,
101 pub type_only: bool,
103 pub depth: u32,
105}
106
107#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct UnresolvedCallee {
112 pub callee: String,
115 pub reason: UnresolvedReason,
117}
118
119#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
121#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
122#[serde(rename_all = "kebab-case")]
123pub enum UnresolvedReason {
124 LocalOrGlobal,
127 MemberOrDynamic,
129}
130
131#[derive(Debug, Clone, Copy)]
133pub struct SymbolChainQuery<'a> {
134 pub file: &'a str,
136 pub symbol: &'a str,
138 pub depth: u32,
140 pub directions: TraceDirections,
142}
143
144#[must_use]
150pub fn trace_symbol_chain(
151 graph: &ModuleGraph,
152 modules: &[ModuleInfo],
153 root: &Path,
154 query: SymbolChainQuery<'_>,
155) -> Option<SymbolChainTrace> {
156 let SymbolChainQuery {
157 file,
158 symbol,
159 depth,
160 directions,
161 } = query;
162 let module = graph
163 .modules
164 .iter()
165 .find(|m| path_matches(&m.path, root, file))?;
166 let rel_file = relativize(&module.path, root);
167
168 let symbol_found = module.exports.iter().any(|e| e.name.to_string() == *symbol);
169
170 let module_by_id: FxHashMap<FileId, &ModuleInfo> =
171 modules.iter().map(|m| (m.file_id, m)).collect();
172
173 let callers = directions
174 .callers
175 .then(|| collect_callers(graph, root, module.file_id, symbol, depth));
176 let callees_walk = directions
177 .callees
178 .then(|| collect_callees(graph, &module_by_id, root, module.file_id, symbol, depth));
179 let (callees, unresolved_callees) = match callees_walk {
180 Some((resolved, unresolved)) => (Some(resolved), Some(unresolved)),
181 None => (None, None),
182 };
183
184 let reason = build_reason(
185 symbol_found,
186 callers.as_deref(),
187 callees.as_deref(),
188 unresolved_callees.as_deref(),
189 );
190
191 Some(SymbolChainTrace {
192 file: rel_file,
193 symbol: symbol.to_string(),
194 symbol_found,
195 depth,
196 best_effort: true,
197 callers,
198 callees,
199 unresolved_callees,
200 reason,
201 })
202}
203
204struct WalkCtx<'a> {
206 graph: &'a ModuleGraph,
207 root: &'a Path,
208 max_depth: u32,
209}
210
211fn collect_callers(
214 graph: &ModuleGraph,
215 root: &Path,
216 target: FileId,
217 symbol: &str,
218 depth: u32,
219) -> Vec<ChainHop> {
220 let ctx = WalkCtx {
221 graph,
222 root,
223 max_depth: depth,
224 };
225 let mut hops = Vec::new();
226 let mut visited: FxHashSet<(FileId, String)> = FxHashSet::default();
227 walk_callers_recursive(&ctx, target, symbol, 1, &mut visited, &mut hops);
228 hops.sort_by(|a, b| {
229 a.depth
230 .cmp(&b.depth)
231 .then_with(|| a.file.cmp(&b.file))
232 .then_with(|| a.local_name.cmp(&b.local_name))
233 });
234 hops
235}
236
237fn walk_callers_recursive(
238 ctx: &WalkCtx<'_>,
239 target: FileId,
240 symbol: &str,
241 current_depth: u32,
242 visited: &mut FxHashSet<(FileId, String)>,
243 hops: &mut Vec<ChainHop>,
244) {
245 if current_depth > ctx.max_depth {
246 return;
247 }
248 for &importer in ctx.graph.importers_of(target) {
249 for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(importer) {
250 if edge_target != target {
251 continue;
252 }
253 for sym in symbols {
254 if !imported_name_matches(&sym.imported_name, symbol) {
255 continue;
256 }
257 let key = (importer, sym.local_name.clone());
258 if !visited.insert(key) {
259 continue;
260 }
261 let importer_path = ctx.graph.modules.get(importer.0 as usize).map_or_else(
262 || PathBuf::from("<unknown>"),
263 |m| relativize(&m.path, ctx.root),
264 );
265 hops.push(ChainHop {
266 file: importer_path,
267 imported_as: imported_name_label(&sym.imported_name),
268 local_name: sym.local_name.clone(),
269 type_only: sym.is_type_only,
270 depth: current_depth,
271 });
272 walk_callers_recursive(
275 ctx,
276 importer,
277 &sym.local_name,
278 current_depth + 1,
279 visited,
280 hops,
281 );
282 }
283 }
284 }
285}
286
287fn collect_callees(
291 graph: &ModuleGraph,
292 module_by_id: &FxHashMap<FileId, &ModuleInfo>,
293 root: &Path,
294 module_id: FileId,
295 _symbol: &str,
296 depth: u32,
297) -> (Vec<ChainHop>, Vec<UnresolvedCallee>) {
298 let ctx = WalkCtx {
299 graph,
300 root,
301 max_depth: depth,
302 };
303 let mut resolved = Vec::new();
304 let mut visited: FxHashSet<FileId> = FxHashSet::default();
305 visited.insert(module_id);
306 walk_callees_recursive(&ctx, module_id, 1, &mut visited, &mut resolved);
307 resolved.sort_by(|a, b| {
308 a.depth
309 .cmp(&b.depth)
310 .then_with(|| a.file.cmp(&b.file))
311 .then_with(|| a.local_name.cmp(&b.local_name))
312 });
313
314 let unresolved = module_by_id
315 .get(&module_id)
316 .map(|info| collect_unresolved_callees(info))
317 .unwrap_or_default();
318
319 (resolved, unresolved)
320}
321
322fn walk_callees_recursive(
323 ctx: &WalkCtx<'_>,
324 module_id: FileId,
325 current_depth: u32,
326 visited: &mut FxHashSet<FileId>,
327 hops: &mut Vec<ChainHop>,
328) {
329 if current_depth > ctx.max_depth {
330 return;
331 }
332 for (edge_target, symbols) in ctx.graph.outgoing_symbol_edges(module_id) {
333 let target_path = ctx.graph.modules.get(edge_target.0 as usize).map_or_else(
334 || PathBuf::from("<unknown>"),
335 |m| relativize(&m.path, ctx.root),
336 );
337 for sym in symbols {
338 if matches!(sym.imported_name, ImportedName::SideEffect) {
340 continue;
341 }
342 hops.push(ChainHop {
343 file: target_path.clone(),
344 imported_as: imported_name_label(&sym.imported_name),
345 local_name: sym.local_name.clone(),
346 type_only: sym.is_type_only,
347 depth: current_depth,
348 });
349 }
350 if visited.insert(edge_target) {
351 walk_callees_recursive(ctx, edge_target, current_depth + 1, visited, hops);
352 }
353 }
354}
355
356fn collect_unresolved_callees(info: &ModuleInfo) -> Vec<UnresolvedCallee> {
361 let import_locals: FxHashSet<&str> =
362 info.imports.iter().map(|i| i.local_name.as_str()).collect();
363
364 let mut out = Vec::new();
365 let mut seen: FxHashSet<&str> = FxHashSet::default();
366 for callee in &info.callee_uses {
367 let path = callee.callee_path.as_str();
368 let leading = path.split('.').next().unwrap_or(path);
369 if import_locals.contains(leading) {
372 continue;
373 }
374 if !seen.insert(path) {
375 continue;
376 }
377 let reason = if path.contains('.') {
378 UnresolvedReason::MemberOrDynamic
379 } else {
380 UnresolvedReason::LocalOrGlobal
381 };
382 out.push(UnresolvedCallee {
383 callee: path.to_string(),
384 reason,
385 });
386 }
387 out.sort_by(|a, b| a.callee.cmp(&b.callee));
388 out
389}
390
391fn build_reason(
392 symbol_found: bool,
393 callers: Option<&[ChainHop]>,
394 callees: Option<&[ChainHop]>,
395 unresolved: Option<&[UnresolvedCallee]>,
396) -> String {
397 if !symbol_found {
398 return "symbol not found as an export of this file; chains are file-scoped best-effort and may be empty".to_string();
399 }
400 let mut parts = Vec::new();
401 if let Some(callers) = callers {
402 parts.push(format!("{} caller hop(s)", callers.len()));
403 }
404 if let Some(callees) = callees {
405 parts.push(format!("{} resolved callee hop(s)", callees.len()));
406 }
407 if let Some(unresolved) = unresolved {
408 parts.push(format!(
409 "{} unresolved callee(s) reported",
410 unresolved.len()
411 ));
412 }
413 format!(
414 "best-effort syntactic chain (ADR-001): {}",
415 parts.join(", ")
416 )
417}
418
419fn imported_name_matches(name: &ImportedName, symbol: &str) -> bool {
422 match name {
423 ImportedName::Named(n) => n == symbol,
424 ImportedName::Default => symbol == "default",
425 ImportedName::Namespace => true,
426 ImportedName::SideEffect => false,
427 }
428}
429
430fn imported_name_label(name: &ImportedName) -> String {
431 match name {
432 ImportedName::Named(name) => name.clone(),
433 ImportedName::Default => "default".to_string(),
434 ImportedName::Namespace => "*".to_string(),
435 ImportedName::SideEffect => "side-effect".to_string(),
436 }
437}
438
439fn relativize(path: &Path, root: &Path) -> PathBuf {
440 path.strip_prefix(root).unwrap_or(path).to_path_buf()
441}
442
443fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
446 let user_path_norm = user_path.replace('\\', "/");
447 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
448 let rel_str = rel.to_string_lossy().replace('\\', "/");
449 let module_str = module_path.to_string_lossy().replace('\\', "/");
450 if rel_str == user_path_norm || module_str == user_path_norm {
451 return true;
452 }
453 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
454 module_path
455 .strip_prefix(&canonical_root)
456 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
457 }) {
458 return true;
459 }
460 module_str.ends_with(&format!("/{user_path_norm}"))
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::analyze::test_support::empty_module;
467
468 #[test]
469 fn imported_name_matches_named_default_namespace() {
470 assert!(imported_name_matches(
471 &ImportedName::Named("foo".to_string()),
472 "foo"
473 ));
474 assert!(!imported_name_matches(
475 &ImportedName::Named("bar".to_string()),
476 "foo"
477 ));
478 assert!(imported_name_matches(&ImportedName::Default, "default"));
479 assert!(!imported_name_matches(&ImportedName::Default, "foo"));
480 assert!(imported_name_matches(&ImportedName::Namespace, "anything"));
482 assert!(!imported_name_matches(&ImportedName::SideEffect, "foo"));
483 }
484
485 #[test]
486 fn unresolved_reason_classifies_member_vs_bare() {
487 let info = ModuleInfo {
488 callee_uses: vec![
489 fallow_types::extract::CalleeUse {
490 callee_path: "localHelper".to_string(),
491 span_start: 0,
492 },
493 fallow_types::extract::CalleeUse {
494 callee_path: "obj.method".to_string(),
495 span_start: 10,
496 },
497 ],
498 ..empty_module()
499 };
500 let unresolved = collect_unresolved_callees(&info);
501 assert_eq!(unresolved.len(), 2);
502 assert_eq!(unresolved[0].callee, "localHelper");
503 assert_eq!(unresolved[0].reason, UnresolvedReason::LocalOrGlobal);
504 assert_eq!(unresolved[1].callee, "obj.method");
505 assert_eq!(unresolved[1].reason, UnresolvedReason::MemberOrDynamic);
506 }
507
508 #[test]
509 fn imported_callees_are_not_listed_as_unresolved() {
510 let info = ModuleInfo {
511 imports: vec![fallow_types::extract::ImportInfo {
512 source: "./dep".to_string(),
513 imported_name: ImportedName::Named("dep".to_string()),
514 local_name: "dep".to_string(),
515 is_type_only: false,
516 from_style: false,
517 span: oxc_span::Span::default(),
518 source_span: oxc_span::Span::default(),
519 }],
520 callee_uses: vec![
521 fallow_types::extract::CalleeUse {
522 callee_path: "dep".to_string(),
523 span_start: 0,
524 },
525 fallow_types::extract::CalleeUse {
526 callee_path: "ghost".to_string(),
527 span_start: 5,
528 },
529 ],
530 ..empty_module()
531 };
532 let unresolved = collect_unresolved_callees(&info);
533 assert_eq!(unresolved.len(), 1);
536 assert_eq!(unresolved[0].callee, "ghost");
537 }
538}