use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fallow_config::{OutputFormat, ProductionAnalysis};
use fallow_types::semantic::SemanticNamespace;
use fallow_types::trace_chain::{
StarExportAmbiguity, SymbolChainQuery, SymbolChainTrace, TraceDirections,
};
use crate::error::emit_error;
use crate::report;
use crate::report::sink::outln;
use crate::{ConfigLoadOptions, load_config_for_analysis};
pub struct TraceChainOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub output: OutputFormat,
pub json_style: crate::json_style::JsonStyle,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub allow_remote_extends: bool,
pub target: String,
pub callers: bool,
pub callees: bool,
pub depth: u32,
}
pub fn run_trace(opts: &TraceChainOptions<'_>) -> ExitCode {
let Some((file, symbol)) = parse_target(&opts.target) else {
return emit_error(
"trace requires a FILE:SYMBOL target (e.g., src/utils.ts:formatDate)",
2,
opts.output,
);
};
let directions = if !opts.callers && !opts.callees {
TraceDirections {
callers: true,
callees: true,
}
} else {
TraceDirections {
callers: opts.callers,
callees: opts.callees,
}
};
let config = match load_config_for_analysis(
opts.root,
opts.config_path,
ConfigLoadOptions {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production_override: None,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
},
ProductionAnalysis::DeadCode,
) {
Ok(config) => config,
Err(code) => return code,
};
let session = match fallow_engine::session::AnalysisSession::from_resolved_config(config) {
Ok(session) => session,
Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
};
let trace = match fallow_engine::trace_chain::trace_symbol_chain_with_session(
&session,
SymbolChainQuery {
file: &file,
symbol: &symbol,
depth: opts.depth,
directions,
},
) {
Ok(Some(trace)) => trace,
Ok(None) => {
return emit_error(
&format!("file '{file}' not found in module graph"),
2,
opts.output,
);
}
Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
};
emit_trace(trace, opts)
}
fn parse_target(target: &str) -> Option<(String, String)> {
crate::selector::parse_file_symbol_selector(target)
.map(|(file, symbol)| (file.to_string(), symbol.to_string()))
}
fn emit_trace(trace: SymbolChainTrace, opts: &TraceChainOptions<'_>) -> ExitCode {
match opts.output {
OutputFormat::Json => {
let value = match fallow_output::serialize_trace_json_output(
trace,
crate::output_runtime::telemetry_analysis_run_id().as_deref(),
) {
Ok(value) => value,
Err(err) => {
return emit_error(
&format!("failed to serialize trace output: {err}"),
2,
opts.output,
);
}
};
report::emit_report_json(&value, "trace", opts.json_style)
}
OutputFormat::Human => {
print_human(&trace, opts.quiet);
ExitCode::SUCCESS
}
_ => emit_error("trace supports --format json or human", 2, opts.output),
}
}
fn print_human(trace: &SymbolChainTrace, quiet: bool) {
outln!("Symbol-level call chain (best-effort, syntactic; OFF the ranked path)");
outln!();
outln!(" symbol: {}:{}", trace.file.display(), trace.symbol);
outln!(" found: {}", trace.symbol_found);
outln!(" depth: {}", trace.depth);
outln!();
if let Some(ambiguity) = trace.star_export_ambiguity.as_ref().filter(|_| !quiet) {
for line in ambiguity_lines(ambiguity) {
outln!("{line}");
}
outln!();
}
if let Some(callers) = trace.callers.as_ref() {
outln!("Callers (up): {}", callers.len());
for hop in callers {
outln!(
" [{}] {} (imported as {} -> local {}){}",
hop.depth,
hop.file.display(),
hop.imported_as,
hop.local_name,
if hop.type_only { " [type-only]" } else { "" }
);
}
outln!();
}
if let Some(callees) = trace.callees.as_ref() {
outln!("Resolved callees (down): {}", callees.len());
for hop in callees {
outln!(
" [{}] {} (imported as {} -> local {}){}",
hop.depth,
hop.file.display(),
hop.imported_as,
hop.local_name,
if hop.type_only { " [type-only]" } else { "" }
);
}
outln!();
}
if let Some(unresolved) = trace.unresolved_callees.as_ref() {
outln!(
"Unresolved callees (reported, not dropped): {}",
unresolved.len()
);
for u in unresolved {
outln!(" {} ({:?})", u.callee, u.reason);
}
outln!();
}
if !quiet {
outln!(" {}", trace.reason);
}
}
fn ambiguity_lines(ambiguity: &StarExportAmbiguity) -> Vec<String> {
let mut lines = vec![format!(
"Ambiguous `export *` ({}): this file exports nothing under this name",
namespace_label(&ambiguity.namespaces)
)];
lines.extend(
ambiguity
.sources
.iter()
.map(|source| format!(" declared in {}", source.display())),
);
lines.push(" fix: keep one declaration, or re-export the intended one by name".to_string());
lines
}
fn namespace_label(namespaces: &[SemanticNamespace]) -> String {
namespaces
.iter()
.map(|namespace| match namespace {
SemanticNamespace::Type => "type space",
SemanticNamespace::Value => "value space",
})
.collect::<Vec<_>>()
.join(" and ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_target_splits_on_last_colon() {
assert_eq!(
parse_target("src/utils.ts:formatDate"),
Some(("src/utils.ts".to_string(), "formatDate".to_string()))
);
assert_eq!(
parse_target("C:/proj/src/a.ts:foo"),
Some(("C:/proj/src/a.ts".to_string(), "foo".to_string()))
);
}
#[test]
fn ambiguity_lines_name_the_colliding_origins_and_the_fix() {
let lines = ambiguity_lines(&StarExportAmbiguity {
sources: vec![PathBuf::from("src/left.ts"), PathBuf::from("src/right.ts")],
namespaces: vec![SemanticNamespace::Value],
});
assert!(lines[0].contains("value space"));
assert!(lines[0].contains("exports nothing under this name"));
assert!(lines.iter().any(|line| line.contains("src/left.ts")));
assert!(lines.iter().any(|line| line.contains("src/right.ts")));
assert!(lines.last().is_some_and(|line| line.contains("fix:")));
}
#[test]
fn ambiguity_lines_report_a_collision_in_both_namespaces() {
let lines = ambiguity_lines(&StarExportAmbiguity {
sources: vec![PathBuf::from("src/left.ts")],
namespaces: vec![SemanticNamespace::Type, SemanticNamespace::Value],
});
assert!(
lines[0].contains("type space and value space"),
"{}",
lines[0]
);
}
#[test]
fn ambiguity_lines_report_a_type_space_only_collision() {
let lines = ambiguity_lines(&StarExportAmbiguity {
sources: vec![PathBuf::from("src/left.ts"), PathBuf::from("src/right.ts")],
namespaces: vec![SemanticNamespace::Type],
});
assert!(lines[0].contains("type space"), "{}", lines[0]);
assert!(!lines[0].contains("value space"), "{}", lines[0]);
}
#[test]
fn parse_target_rejects_empty_halves() {
assert!(parse_target("src/utils.ts:").is_none());
assert!(parse_target(":foo").is_none());
assert!(parse_target("no-colon").is_none());
}
#[test]
fn parse_target_pins_the_whole_selector_table() {
assert_eq!(parse_target(""), None);
assert_eq!(parse_target(":"), None);
assert_eq!(parse_target(" : "), None);
assert_eq!(parse_target("\t:foo"), None);
assert_eq!(
parse_target("a:b:c"),
Some(("a:b".to_string(), "c".to_string()))
);
assert_eq!(
parse_target(" src/a.ts : foo "),
Some((" src/a.ts ".to_string(), " foo ".to_string()))
);
}
}