backtrace_ls/
text.rs

1//! Text mode for human-readable output.
2//!
3//! This module provides an alternative to the LSP server that prints test
4//! failures directly to stdout/stderr in a readable format. It watches for
5//! file changes and re-runs tests automatically.
6
7use std::{
8    env::current_dir,
9    io::{self, Write},
10    path::Path,
11    sync::mpsc,
12    time::Duration,
13};
14
15use notify::{RecommendedWatcher, RecursiveMode, Watcher};
16
17use crate::{Config, TestFailure, error::LSError, runner::DynRunner, workspace};
18
19/// Run text mode with file watching.
20pub async fn run(config: Config) -> Result<(), LSError> {
21    let project_dir = config
22        .workspace_root
23        .clone()
24        .or_else(|| current_dir().ok())
25        .ok_or(LSError::NoWorkspaceFolders)?;
26
27    eprintln!("backtrace-ls: watching {}", project_dir.display());
28
29    // Resolve single runner: use explicit config or auto-detect
30    let runner_name = config
31        .runner
32        .clone()
33        .map_or_else(|| workspace::detect_runner(&project_dir), Ok)?;
34
35    let runner = <dyn DynRunner>::from_name(&runner_name)?;
36    eprintln!("backtrace-ls: using runner '{runner_name}'");
37
38    // Initial run
39    run_tests(&project_dir, runner, &config.extra_args).await?;
40
41    // Set up file watcher
42    let (tx, rx) = mpsc::channel();
43    let mut watcher: RecommendedWatcher = Watcher::new(
44        tx,
45        notify::Config::default().with_poll_interval(Duration::from_secs(1)),
46    )
47    .map_err(|e| LSError::Generic(format!("Failed to create watcher: {e}")))?;
48
49    watcher
50        .watch(&project_dir, RecursiveMode::Recursive)
51        .map_err(|e| LSError::Generic(format!("Failed to watch directory: {e}")))?;
52
53    eprintln!("backtrace-ls: watching for changes...\n");
54
55    // Watch for file changes
56    loop {
57        match rx.recv() {
58            Ok(Ok(event)) => {
59                if should_rerun(&event, runner) {
60                    eprintln!("\n--- File changed, re-running tests ---\n");
61                    if let Err(e) = run_tests(&project_dir, runner, &config.extra_args).await {
62                        eprintln!("backtrace-ls: error running tests: {e}");
63                    }
64                }
65            }
66            Ok(Err(e)) => {
67                eprintln!("backtrace-ls: watch error: {e}");
68            }
69            Err(e) => {
70                eprintln!("backtrace-ls: channel error: {e}");
71                break;
72            }
73        }
74    }
75
76    Ok(())
77}
78
79fn should_rerun(event: &notify::Event, runner: &'static dyn DynRunner) -> bool {
80    use notify::EventKind;
81
82    // Only react to modifications and creations
83    if !matches!(
84        event.kind,
85        EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
86    ) {
87        return false;
88    }
89
90    // Check if any changed file matches the runner's extensions
91    event.paths.iter().any(|path| {
92        path.extension()
93            .and_then(|e| e.to_str())
94            .is_some_and(|ext| runner.file_extensions().contains(&ext))
95    })
96}
97
98async fn run_tests(
99    project_dir: &Path,
100    runner: &'static dyn DynRunner,
101    extra_args: &[String],
102) -> Result<(), LSError> {
103    let file_paths = workspace::walk_files(project_dir, runner.file_extensions());
104
105    if file_paths.is_empty() {
106        eprintln!("No test files found");
107        return Ok(());
108    }
109
110    let workspaces = runner.detect_workspaces(&file_paths);
111    let mut total_failures = 0;
112
113    for (workspace, paths) in workspaces.map {
114        eprintln!(
115            "Running {} on {} files in {}",
116            runner.name(),
117            paths.len(),
118            workspace.display()
119        );
120
121        match runner.run(&paths, &workspace, extra_args).await {
122            Ok(failures) => {
123                total_failures += failures.len();
124                print_failures(&failures);
125            }
126            Err(e) => {
127                eprintln!("  Error: {e}");
128            }
129        }
130    }
131
132    if total_failures == 0 {
133        println!("\nAll tests passed!");
134    } else {
135        println!("\n{total_failures} test failure(s)");
136    }
137
138    Ok(())
139}
140
141fn print_failures(failures: &[TestFailure]) {
142    let stdout = io::stdout();
143    let mut handle = stdout.lock();
144
145    for failure in failures {
146        writeln!(handle).ok();
147
148        // Print test name/context
149        if let Some(ctx) = &failure.context {
150            writeln!(
151                handle,
152                "FAIL: {} ({}:{})",
153                ctx.name,
154                ctx.span.path.display(),
155                ctx.span.range.start.line + 1
156            )
157            .ok();
158        } else {
159            writeln!(handle, "FAIL: {}", failure.failure_id()).ok();
160        }
161
162        // Print assertion/panic location and message
163        if let Some(user_failure) = &failure.user_facing_failure {
164            writeln!(
165                handle,
166                "  at {}:{}",
167                user_failure.span.path.display(),
168                user_failure.span.range.start.line + 1
169            )
170            .ok();
171
172            // Print message with indentation
173            for line in user_failure.message.lines() {
174                writeln!(handle, "  | {line}").ok();
175            }
176        }
177
178        // Print backtrace frames
179        if !failure.stack_frames.is_empty() {
180            writeln!(handle, "  backtrace:").ok();
181            for (i, frame) in failure.stack_frames.iter().enumerate() {
182                writeln!(
183                    handle,
184                    "    {i}: {} ({}:{})",
185                    frame.function_name,
186                    frame.path.display(),
187                    frame.range.start.line + 1
188                )
189                .ok();
190            }
191        }
192    }
193}