backtrace_ls/
lib.rs

1//! Testing Language Server - LSP for running tests and showing diagnostics.
2
3use std::{
4    collections::HashMap,
5    fs, io,
6    path::{Path, PathBuf},
7    process::{Output, Stdio},
8};
9
10use lsp_types::{Diagnostic, DiagnosticRelatedInformation, Location, Position, Range, Uri};
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use tokio::process::Command;
14use tree_sitter::Point;
15
16mod config;
17mod error;
18mod lsp;
19mod runner;
20mod workspace;
21
22pub use lsp::server;
23
24// Language-specific modules
25mod go;
26mod javascript;
27mod php;
28mod rust;
29
30// Re-export config types for convenience
31use config::{AdapterConfig, Config};
32
33/// If the character value is greater than the line length it defaults back to
34/// the line length.
35pub(crate) const MAX_CHAR_LENGTH: u32 = 10000;
36
37// --- Utility Functions ---
38
39/// Run a command asynchronously and capture its output.
40pub(crate) async fn run_command(cmd: &mut Command) -> io::Result<Output> {
41    cmd.stdout(Stdio::piped())
42        .stderr(Stdio::piped())
43        .output()
44        .await
45}
46
47/// Write test command output to a log file for debugging.
48pub(crate) fn write_result_log(file_name: &str, output: &Output) -> io::Result<()> {
49    let stdout_str = String::from_utf8(output.stdout.clone()).unwrap_or_default();
50    let stderr_str = String::from_utf8(output.stderr.clone()).unwrap_or_default();
51    let content = format!("stdout:\n{stdout_str}\nstderr:\n{stderr_str}");
52    let cache = &config::CONFIG.cache_dir;
53    fs::create_dir_all(cache)?;
54    let log_path = cache.join(file_name);
55    fs::write(&log_path, content)?;
56    Ok(())
57}
58
59/// Clean ANSI escape sequences from text.
60#[must_use]
61pub(crate) fn clean_ansi(input: &str) -> String {
62    let re = Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]").unwrap();
63    re.replace_all(input, "").to_string()
64}
65
66/// Convert a file path to a file:// URI string.
67/// Returns None if the path is not absolute.
68#[must_use]
69pub(crate) fn path_to_uri_string(path: &Path) -> Option<String> {
70    if !path.is_absolute() {
71        return None;
72    }
73    let path_str = path.to_string_lossy();
74    // On Unix, paths start with /, so file:///path
75    // On Windows, paths start with drive letter, so file:///C:/path
76    #[cfg(windows)]
77    let uri_string = format!("file:///{}", path_str.replace('\\', "/"));
78    #[cfg(not(windows))]
79    let uri_string = format!("file://{path_str}");
80    Some(uri_string)
81}
82
83/// Convert a file path to a file:// URI.
84/// Returns None if the path is not absolute.
85#[must_use]
86fn path_to_uri(path: &Path) -> Option<Uri> {
87    path_to_uri_string(path).and_then(|s| s.parse().ok())
88}
89
90/// Convert a tree-sitter Point to a Range covering the start of that line.
91#[must_use]
92pub(crate) const fn point_to_start_range(point: Point) -> Range {
93    Range {
94        start: Position {
95            line: point.row as u32,
96            character: point.column as u32,
97        },
98        end: Position {
99            line: point.row as u32,
100            character: MAX_CHAR_LENGTH,
101        },
102    }
103}
104
105/// Convert a tree-sitter Point to a Range covering the end of that line.
106#[must_use]
107pub(crate) const fn point_to_end_range(point: Point) -> Range {
108    Range {
109        start: Position {
110            line: point.row as u32,
111            character: 0,
112        },
113        end: Position {
114            line: point.row as u32,
115            character: point.column as u32,
116        },
117    }
118}
119
120// --- Core Types ---
121
122/// A single test item discovered in a file.
123#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
124pub(crate) struct TestItem {
125    pub(crate) id: String,
126    pub(crate) name: String,
127    pub(crate) path: PathBuf,
128    pub(crate) start_position: Range,
129    pub(crate) end_position: Range,
130}
131
132/// A location in a source file (path + range).
133#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
134pub(crate) struct SourceSpan {
135    pub(crate) path: PathBuf,
136    pub(crate) range: Range,
137}
138
139/// A backtrace frame with function name and location.
140#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
141pub(crate) struct BacktraceFrame {
142    pub(crate) function_name: String,
143    pub(crate) path: PathBuf,
144    pub(crate) range: Range,
145}
146
147/// A single test failure with its diagnostic spans.
148#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
149pub(crate) struct TestFailure {
150    /// The assertion/panic location (ERROR severity)
151    pub(crate) assertion: Option<SourceSpan>,
152    /// The test function definition (WARNING severity)
153    pub(crate) test_function: Option<SourceSpan>,
154    /// Backtrace frames in user code (INFO severity)
155    pub(crate) backtrace: Vec<BacktraceFrame>,
156    /// The failure message
157    pub(crate) message: String,
158    /// Short test name for the warning diagnostic
159    pub(crate) test_name: String,
160    /// Diagnostic source (e.g., "cargo-test", "jest")
161    pub(crate) source: String,
162    /// Diagnostic code (e.g., "unit-test-failed")
163    pub(crate) code: String,
164}
165
166impl TestFailure {
167    /// Generate a unique ID for this failure based on test name and assertion
168    /// location.
169    #[must_use]
170    pub(crate) fn failure_id(&self) -> String {
171        let location = self
172            .assertion
173            .as_ref()
174            .or(self.test_function.as_ref())
175            .map(|s| format!("{}:{}", s.path.display(), s.range.start.line))
176            .unwrap_or_default();
177        format!("{}@{}", self.test_name, location)
178    }
179
180    /// Get all navigation spans in order: assertion, backtrace frames, test
181    /// function.
182    #[must_use]
183    pub(crate) fn all_spans(&self) -> Vec<SourceSpan> {
184        let mut spans = Vec::new();
185        if let Some(assertion) = &self.assertion {
186            spans.push(assertion.clone());
187        }
188        spans.extend(self.backtrace.iter().map(|frame| SourceSpan {
189            path: frame.path.clone(),
190            range: frame.range,
191        }));
192        if let Some(test_fn) = &self.test_function {
193            spans.push(test_fn.clone());
194        }
195        spans
196    }
197}
198
199impl TestFailure {
200    /// Convert to LSP diagnostics grouped by file path.
201    /// Each diagnostic includes related information linking to other spans in
202    /// this failure.
203    #[must_use]
204    fn to_diagnostics(&self) -> Vec<(PathBuf, Diagnostic)> {
205        use lsp_types::{DiagnosticSeverity, NumberOrString};
206
207        let failure_id = self.failure_id();
208        let mut result = Vec::new();
209
210        // Build related information for all spans
211        let related_info = self.build_related_info();
212
213        // Assertion diagnostic (ERROR)
214        if let Some(span) = &self.assertion {
215            let related = filter_related_info(&related_info, span);
216            result.push((
217                span.path.clone(),
218                Diagnostic {
219                    range: span.range,
220                    message: self.message.clone(),
221                    severity: Some(DiagnosticSeverity::ERROR),
222                    source: Some(self.source.clone()),
223                    code: Some(NumberOrString::String(self.code.clone())),
224                    related_information: if related.is_empty() {
225                        None
226                    } else {
227                        Some(related)
228                    },
229                    data: Some(serde_json::json!({
230                        "failureId": failure_id,
231                        "spanType": "assertion",
232                        "spanIndex": 0
233                    })),
234                    ..Diagnostic::default()
235                },
236            ));
237        }
238
239        // Backtrace diagnostics (INFO)
240        for (idx, frame) in self.backtrace.iter().enumerate() {
241            let msg = format!("frame {}: {}", idx + 1, frame.function_name);
242            let frame_span = SourceSpan {
243                path: frame.path.clone(),
244                range: frame.range,
245            };
246            let related = filter_related_info(&related_info, &frame_span);
247            result.push((
248                frame.path.clone(),
249                Diagnostic {
250                    range: frame.range,
251                    message: msg,
252                    severity: Some(DiagnosticSeverity::INFORMATION),
253                    source: Some(self.source.clone()),
254                    code: Some(NumberOrString::String("backtrace".to_string())),
255                    related_information: if related.is_empty() {
256                        None
257                    } else {
258                        Some(related)
259                    },
260                    data: Some(serde_json::json!({
261                        "failureId": failure_id,
262                        "spanType": "backtrace",
263                        "spanIndex": idx + 1  // 0 is assertion
264                    })),
265                    ..Diagnostic::default()
266                },
267            ));
268        }
269
270        // Test function diagnostic (WARNING)
271        if let Some(span) = &self.test_function {
272            let msg = format!("test `{}` failed", self.test_name);
273            let related = filter_related_info(&related_info, span);
274            let span_index = 1 + self.backtrace.len(); // after assertion and backtrace
275            result.push((
276                span.path.clone(),
277                Diagnostic {
278                    range: span.range,
279                    message: msg,
280                    severity: Some(DiagnosticSeverity::WARNING),
281                    source: Some(self.source.clone()),
282                    code: Some(NumberOrString::String("test-failed".to_string())),
283                    related_information: if related.is_empty() {
284                        None
285                    } else {
286                        Some(related)
287                    },
288                    data: Some(serde_json::json!({
289                        "failureId": failure_id,
290                        "spanType": "test_function",
291                        "spanIndex": span_index
292                    })),
293                    ..Diagnostic::default()
294                },
295            ));
296        }
297
298        result
299    }
300
301    fn build_related_info(&self) -> Vec<DiagnosticRelatedInformation> {
302        let mut info = Vec::new();
303
304        if let Some(span) = &self.assertion
305            && let Some(uri) = path_to_uri(&span.path)
306        {
307            info.push(DiagnosticRelatedInformation {
308                location: Location {
309                    uri,
310                    range: span.range,
311                },
312                message: "assertion".to_string(),
313            });
314        }
315
316        for (idx, frame) in self.backtrace.iter().enumerate() {
317            if let Some(uri) = path_to_uri(&frame.path) {
318                info.push(DiagnosticRelatedInformation {
319                    location: Location {
320                        uri,
321                        range: frame.range,
322                    },
323                    message: format!("frame {}: {}", idx + 1, frame.function_name),
324                });
325            }
326        }
327
328        if let Some(span) = &self.test_function
329            && let Some(uri) = path_to_uri(&span.path)
330        {
331            info.push(DiagnosticRelatedInformation {
332                location: Location {
333                    uri,
334                    range: span.range,
335                },
336                message: format!("test `{}`", self.test_name),
337            });
338        }
339
340        info
341    }
342}
343
344fn filter_related_info(
345    all: &[lsp_types::DiagnosticRelatedInformation],
346    current_span: &SourceSpan,
347) -> Vec<lsp_types::DiagnosticRelatedInformation> {
348    let current_uri = path_to_uri(&current_span.path);
349    all.iter()
350        .filter(|info| {
351            // Exclude the current span from related info
352            current_uri.as_ref() != Some(&info.location.uri)
353                || info.location.range != current_span.range
354        })
355        .cloned()
356        .collect()
357}
358
359/// Diagnostics for a single file (used for LSP publishing).
360#[derive(Debug, Clone)]
361pub(crate) struct FileDiagnostics {
362    pub(crate) path: PathBuf,
363    pub(crate) diagnostics: Vec<Diagnostic>,
364}
365
366/// Convert test failures to file diagnostics grouped by path.
367pub(crate) fn failures_to_file_diagnostics(
368    failures: impl IntoIterator<Item = TestFailure>,
369) -> impl Iterator<Item = FileDiagnostics> {
370    use std::collections::HashMap;
371
372    let mut by_file: HashMap<PathBuf, Vec<Diagnostic>> = HashMap::new();
373
374    for failure in failures {
375        for (path, diag) in failure.to_diagnostics() {
376            by_file.entry(path).or_default().push(diag);
377        }
378    }
379
380    by_file
381        .into_iter()
382        .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics })
383}
384
385/// Map of workspace roots to their contained files.
386#[derive(Debug, Serialize, Clone, Deserialize, Default)]
387pub(crate) struct Workspaces {
388    pub(crate) map: HashMap<PathBuf, Vec<PathBuf>>,
389}
390
391/// Analysis result for a workspace with its adapter configuration.
392#[derive(Debug, Serialize, Deserialize, Clone)]
393pub struct WorkspaceAnalysis {
394    pub adapter_config: AdapterConfig,
395    pub workspaces: Workspaces,
396}
397
398impl WorkspaceAnalysis {
399    #[must_use]
400    pub(crate) const fn new(adapter_config: AdapterConfig, workspaces: Workspaces) -> Self {
401        Self {
402            adapter_config,
403            workspaces,
404        }
405    }
406}