backtrace_ls/
lib.rs

1//! Testing Language Server - LSP for running tests and showing diagnostics.
2
3use std::{
4    collections::HashMap,
5    io,
6    path::{Path, PathBuf},
7    process::{Output, Stdio},
8};
9
10use async_lsp::lsp_types::{
11    Diagnostic, DiagnosticRelatedInformation, Location, Position, Range, Url,
12};
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15use tokio::process::Command;
16use tree_sitter::{Language, Point, Query, QueryCursor, Tree};
17
18pub mod cli;
19mod config;
20mod error;
21mod lsp;
22mod runner;
23pub mod text;
24mod workspace;
25
26pub use lsp::server;
27
28// Language-specific modules
29mod cpp;
30mod go;
31mod javascript;
32mod php;
33mod rust;
34
35// Re-export config types for convenience
36use config::Config;
37
38/// If the character value is greater than the line length it defaults back to
39/// the line length.
40pub(crate) const MAX_CHAR_LENGTH: u32 = 10000;
41
42// --- Utility Functions ---
43
44/// Run a command asynchronously and capture its output.
45pub(crate) async fn run_command(cmd: &mut Command) -> io::Result<Output> {
46    log::debug!("Running shell command:\n{cmd:?}");
47    cmd.stdout(Stdio::piped())
48        .stderr(Stdio::piped())
49        .output()
50        .await
51}
52
53/// Clean ANSI escape sequences from text.
54#[must_use]
55pub(crate) fn clean_ansi(input: &str) -> String {
56    let re = Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]").unwrap();
57    re.replace_all(input, "").to_string()
58}
59
60/// Convert a file path to a file:// URI string.
61/// Returns None if the path is not absolute.
62#[must_use]
63pub(crate) fn path_to_uri_string(path: &Path) -> Option<String> {
64    if !path.is_absolute() {
65        return None;
66    }
67    let path_str = path.to_string_lossy();
68    // On Unix, paths start with /, so file:///path
69    // On Windows, paths start with drive letter, so file:///C:/path
70    #[cfg(windows)]
71    let uri_string = format!("file:///{}", path_str.replace('\\', "/"));
72    #[cfg(not(windows))]
73    let uri_string = format!("file://{path_str}");
74    Some(uri_string)
75}
76
77/// Convert a file path to a file:// URI.
78/// Returns None if the path is not absolute.
79#[must_use]
80fn path_to_uri(path: &Path) -> Option<Url> {
81    path_to_uri_string(path).and_then(|s| s.parse().ok())
82}
83
84/// Convert a tree-sitter Point to a Range covering the start of that line.
85#[must_use]
86pub(crate) const fn point_to_start_range(point: Point) -> Range {
87    Range {
88        start: Position {
89            line: point.row as u32,
90            character: point.column as u32,
91        },
92        end: Position {
93            line: point.row as u32,
94            character: MAX_CHAR_LENGTH,
95        },
96    }
97}
98
99/// Convert a tree-sitter Point to a Range covering the end of that line.
100#[must_use]
101pub(crate) const fn point_to_end_range(point: Point) -> Range {
102    Range {
103        start: Position {
104            line: point.row as u32,
105            character: 0,
106        },
107        end: Position {
108            line: point.row as u32,
109            character: point.column as u32,
110        },
111    }
112}
113
114/// Find the smallest function span containing a given position using a
115/// tree-sitter query. The query should capture function nodes as
116/// `@function.definition`.
117#[must_use]
118pub(crate) fn find_smallest_function_span(
119    tree: &Tree,
120    position: Point,
121    language: &Language,
122    query_str: &str,
123) -> Option<Range> {
124    let query = Query::new(language, query_str).ok()?;
125    let mut cursor = QueryCursor::new();
126
127    let mut best_match: Option<Range> = None;
128    let mut best_size = usize::MAX;
129
130    for m in cursor.matches(&query, tree.root_node(), &[] as &[u8]) {
131        for capture in m.captures {
132            let node = capture.node;
133            let start = node.start_position();
134            let end = node.end_position();
135
136            if point_within_range(position, start, end) {
137                let size = node.byte_range().len();
138                if size < best_size {
139                    best_size = size;
140                    best_match = Some(Range {
141                        start: Position {
142                            line: start.row as u32,
143                            character: start.column as u32,
144                        },
145                        end: Position {
146                            line: end.row as u32,
147                            character: end.column as u32,
148                        },
149                    });
150                }
151            }
152        }
153    }
154
155    best_match
156}
157
158/// Check if a position is within a range defined by start and end points.
159const fn point_within_range(position: Point, start: Point, end: Point) -> bool {
160    if position.row < start.row || position.row > end.row {
161        return false;
162    }
163    if position.row == start.row && position.column < start.column {
164        return false;
165    }
166    if position.row == end.row && position.column > end.column {
167        return false;
168    }
169    true
170}
171
172// --- Core Types ---
173
174/// A single test item discovered in a file.
175#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
176pub(crate) struct TestItem {
177    pub(crate) id: String,
178    pub(crate) name: String,
179    pub(crate) path: PathBuf,
180    pub(crate) start_position: Range,
181    pub(crate) end_position: Range,
182}
183
184/// A location in a source file (path + range).
185#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
186pub(crate) struct SourceSpan {
187    pub(crate) path: PathBuf,
188    pub(crate) range: Range,
189}
190
191/// A backtrace frame with function name and location.
192#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
193pub(crate) struct BacktraceFrame {
194    pub(crate) function_name: String,
195    pub(crate) path: PathBuf,
196    pub(crate) range: Range,
197    /// The enclosing function span (if found via tree-sitter)
198    pub(crate) function_span: Option<Range>,
199}
200
201/// Test function context: location and name.
202#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
203pub(crate) struct FailureContext {
204    /// The test function definition location
205    pub(crate) span: SourceSpan,
206    /// Short name for context (e.g., for Rust test, the test function name that
207    /// failed `test_add_fails`)
208    pub(crate) name: String,
209}
210
211/// User-facing failure: assertion/panic location with message.
212#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
213pub(crate) struct UserFacingFailure {
214    /// The assertion/panic location
215    pub(crate) span: SourceSpan,
216    /// The failure message (assertion text or panic message)
217    pub(crate) message: String,
218    /// The enclosing function span (if found via tree-sitter)
219    pub(crate) function_span: Option<Range>,
220}
221
222/// A single test failure with its diagnostic spans.
223#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
224pub(crate) struct TestFailure {
225    /// The assertion/panic or most important user facing error (ERROR severity)
226    pub(crate) user_facing_failure: Option<UserFacingFailure>,
227    /// The context surrounding the user error (WARNING severity)
228    pub(crate) context: Option<FailureContext>,
229    /// Backtrace frames in user code (INFO severity)
230    pub(crate) stack_frames: Vec<BacktraceFrame>,
231    /// Diagnostic source (e.g., "cargo-test", "jest")
232    pub(crate) runner_id: String,
233    /// Type of failure (e.g., "unit-test-failed")
234    pub(crate) failure_type_code: String,
235    /// Human-readable context type (e.g., "test", "test method", "describe
236    /// block")
237    pub(crate) context_kind: String,
238}
239
240impl TestFailure {
241    /// Primary span used for cache invalidation (assertion or context
242    /// location).
243    #[must_use]
244    pub(crate) fn primary_span(&self) -> Option<&SourceSpan> {
245        self.user_facing_failure
246            .as_ref()
247            .map(|f| &f.span)
248            .or_else(|| self.context.as_ref().map(|c| &c.span))
249    }
250
251    /// Generate a unique ID for this failure based on test name and assertion
252    /// location.
253    #[must_use]
254    pub(crate) fn failure_id(&self) -> String {
255        let location = self
256            .primary_span()
257            .map(|s| format!("{}:{}", s.path.display(), s.range.start.line))
258            .unwrap_or_default();
259        let name = self.context.as_ref().map_or("unknown", |c| c.name.as_str());
260        format!("{name}@{location}")
261    }
262
263    /// Get all navigation spans in order: assertion, backtrace frames, test
264    /// function.
265    #[must_use]
266    pub(crate) fn all_spans(&self) -> Vec<SourceSpan> {
267        let mut spans = Vec::new();
268        if let Some(failure) = &self.user_facing_failure {
269            spans.push(failure.span.clone());
270        }
271        spans.extend(self.stack_frames.iter().map(|frame| SourceSpan {
272            path: frame.path.clone(),
273            range: frame.range,
274        }));
275        if let Some(ctx) = &self.context {
276            spans.push(ctx.span.clone());
277        }
278        spans
279    }
280}
281
282impl TestFailure {
283    /// Convert to LSP diagnostics grouped by file path.
284    /// Each diagnostic includes related information linking to other spans in
285    /// this failure.
286    ///
287    /// When `show_surrounding_function` is true, additional HINT diagnostics
288    /// are emitted to highlight the surrounding function body.
289    #[must_use]
290    #[allow(clippy::too_many_lines)]
291    fn to_diagnostics(&self, show_surrounding_function: bool) -> Vec<(PathBuf, Diagnostic)> {
292        use async_lsp::lsp_types::{DiagnosticSeverity, NumberOrString};
293
294        let failure_id = self.failure_id();
295        let mut result = Vec::new();
296
297        // Build related information for all spans
298        let related_info = self.build_related_info();
299
300        // Assertion diagnostic (ERROR) - always at the specific assertion line
301        if let Some(failure) = &self.user_facing_failure {
302            let related = filter_related_info(&related_info, &failure.span);
303            result.push((
304                failure.span.path.clone(),
305                Diagnostic {
306                    range: failure.span.range,
307                    message: failure.message.clone(),
308                    severity: Some(DiagnosticSeverity::ERROR),
309                    source: Some(self.runner_id.clone()),
310                    code: Some(NumberOrString::String(self.failure_type_code.clone())),
311                    related_information: if related.is_empty() {
312                        None
313                    } else {
314                        Some(related)
315                    },
316                    data: Some(serde_json::json!({
317                        "failureId": failure_id,
318                        "spanType": "assertion",
319                        "spanIndex": 0
320                    })),
321                    ..Diagnostic::default()
322                },
323            ));
324
325            // Additional HINT diagnostic for function body highlighting
326            if show_surrounding_function
327                && let Some(fn_span) = failure.function_span
328                && fn_span != failure.span.range
329            {
330                result.push((
331                    failure.span.path.clone(),
332                    Diagnostic {
333                        range: fn_span,
334                        message: "contains failing assertion".to_string(),
335                        severity: Some(DiagnosticSeverity::HINT),
336                        source: Some(self.runner_id.clone()),
337                        code: Some(NumberOrString::String("function-context".to_string())),
338                        related_information: None,
339                        data: Some(serde_json::json!({
340                            "failureId": failure_id,
341                            "spanType": "function_highlight",
342                            "spanIndex": 0
343                        })),
344                        ..Diagnostic::default()
345                    },
346                ));
347            }
348        }
349
350        // Backtrace diagnostics (INFO) - always at the specific line
351        for (idx, frame) in self.stack_frames.iter().enumerate() {
352            let msg = format!("frame {}: {}", idx, frame.function_name);
353            let frame_span = SourceSpan {
354                path: frame.path.clone(),
355                range: frame.range,
356            };
357            let related = filter_related_info(&related_info, &frame_span);
358            result.push((
359                frame.path.clone(),
360                Diagnostic {
361                    range: frame.range,
362                    message: msg,
363                    severity: Some(DiagnosticSeverity::INFORMATION),
364                    source: Some(self.runner_id.clone()),
365                    code: Some(NumberOrString::String("backtrace".to_string())),
366                    related_information: if related.is_empty() {
367                        None
368                    } else {
369                        Some(related)
370                    },
371                    data: Some(serde_json::json!({
372                        "failureId": failure_id,
373                        "spanType": "backtrace",
374                        "spanIndex": idx
375                    })),
376                    ..Diagnostic::default()
377                },
378            ));
379
380            // Additional HINT diagnostic for function body highlighting
381            if show_surrounding_function
382                && let Some(fn_span) = frame.function_span
383                && fn_span != frame.range
384            {
385                result.push((
386                    frame.path.clone(),
387                    Diagnostic {
388                        range: fn_span,
389                        message: format!("contains frame {}: {}", idx, frame.function_name),
390                        severity: Some(DiagnosticSeverity::HINT),
391                        source: Some(self.runner_id.clone()),
392                        code: Some(NumberOrString::String("function-context".to_string())),
393                        related_information: None,
394                        data: Some(serde_json::json!({
395                            "failureId": failure_id,
396                            "spanType": "function_highlight",
397                            "spanIndex": idx
398                        })),
399                        ..Diagnostic::default()
400                    },
401                ));
402            }
403        }
404
405        // Test function diagnostic (WARNING)
406        // Skip when show_surrounding_function is enabled since the function
407        // is already highlighted as part of the assertion or backtrace frame spans.
408        if !show_surrounding_function && let Some(ctx) = &self.context {
409            let msg = format!("`{}` failed", ctx.name);
410            let related = filter_related_info(&related_info, &ctx.span);
411            let span_index = 1 + self.stack_frames.len(); // after assertion and backtrace
412            result.push((
413                ctx.span.path.clone(),
414                Diagnostic {
415                    range: ctx.span.range,
416                    message: msg,
417                    severity: Some(DiagnosticSeverity::WARNING),
418                    source: Some(self.runner_id.clone()),
419                    code: Some(NumberOrString::String("test-failed".to_string())),
420                    related_information: if related.is_empty() {
421                        None
422                    } else {
423                        Some(related)
424                    },
425                    data: Some(serde_json::json!({
426                        "failureId": failure_id,
427                        "spanType": "test_function",
428                        "spanIndex": span_index
429                    })),
430                    ..Diagnostic::default()
431                },
432            ));
433        }
434
435        result
436    }
437
438    fn build_related_info(&self) -> Vec<DiagnosticRelatedInformation> {
439        let mut info = Vec::new();
440
441        if let Some(failure) = &self.user_facing_failure
442            && let Some(uri) = path_to_uri(&failure.span.path)
443        {
444            info.push(DiagnosticRelatedInformation {
445                location: Location {
446                    uri,
447                    range: failure.span.range,
448                },
449                message: "assertion".to_string(),
450            });
451        }
452
453        for (idx, frame) in self.stack_frames.iter().enumerate() {
454            if let Some(uri) = path_to_uri(&frame.path) {
455                info.push(DiagnosticRelatedInformation {
456                    location: Location {
457                        uri,
458                        range: frame.range,
459                    },
460                    message: format!("frame {}: {}", idx, frame.function_name),
461                });
462            }
463        }
464
465        if let Some(ctx) = &self.context
466            && let Some(uri) = path_to_uri(&ctx.span.path)
467        {
468            info.push(DiagnosticRelatedInformation {
469                location: Location {
470                    uri,
471                    range: ctx.span.range,
472                },
473                message: format!("test `{}`", ctx.name),
474            });
475        }
476
477        info
478    }
479}
480
481fn filter_related_info(
482    all: &[DiagnosticRelatedInformation],
483    current_span: &SourceSpan,
484) -> Vec<DiagnosticRelatedInformation> {
485    let current_uri = path_to_uri(&current_span.path);
486    all.iter()
487        .filter(|info| {
488            // Exclude the current span from related info
489            current_uri.as_ref() != Some(&info.location.uri)
490                || info.location.range != current_span.range
491        })
492        .cloned()
493        .collect()
494}
495
496/// Diagnostics for a single file (used for LSP publishing).
497#[derive(Debug, Clone)]
498pub(crate) struct FileDiagnostics {
499    pub(crate) path: PathBuf,
500    pub(crate) diagnostics: Vec<Diagnostic>,
501}
502
503/// Convert test failures to file diagnostics grouped by path.
504///
505/// When `show_surrounding_function` is true, diagnostic ranges span the
506/// entire surrounding function body. When false, diagnostics point to
507/// the specific line only.
508pub(crate) fn failures_to_file_diagnostics(
509    failures: impl IntoIterator<Item = TestFailure>,
510    show_surrounding_function: bool,
511) -> impl Iterator<Item = FileDiagnostics> {
512    use std::collections::HashMap;
513
514    let mut by_file: HashMap<PathBuf, Vec<Diagnostic>> = HashMap::new();
515
516    for failure in failures {
517        for (path, diag) in failure.to_diagnostics(show_surrounding_function) {
518            by_file.entry(path).or_default().push(diag);
519        }
520    }
521
522    by_file
523        .into_iter()
524        .map(|(path, diagnostics)| FileDiagnostics { path, diagnostics })
525}
526
527/// Map of workspace roots to their contained files.
528#[derive(Debug, Serialize, Clone, Deserialize, Default)]
529
530pub struct Workspaces {
531    pub(crate) map: HashMap<PathBuf, Vec<PathBuf>>,
532}