1use 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
28mod cpp;
30mod go;
31mod javascript;
32mod php;
33mod rust;
34
35use config::Config;
37
38pub(crate) const MAX_CHAR_LENGTH: u32 = 10000;
41
42pub(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#[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#[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 #[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#[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#[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#[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#[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
158const 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#[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#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
186pub(crate) struct SourceSpan {
187 pub(crate) path: PathBuf,
188 pub(crate) range: Range,
189}
190
191#[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 pub(crate) function_span: Option<Range>,
199}
200
201#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
203pub(crate) struct FailureContext {
204 pub(crate) span: SourceSpan,
206 pub(crate) name: String,
209}
210
211#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
213pub(crate) struct UserFacingFailure {
214 pub(crate) span: SourceSpan,
216 pub(crate) message: String,
218 pub(crate) function_span: Option<Range>,
220}
221
222#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Clone)]
224pub(crate) struct TestFailure {
225 pub(crate) user_facing_failure: Option<UserFacingFailure>,
227 pub(crate) context: Option<FailureContext>,
229 pub(crate) stack_frames: Vec<BacktraceFrame>,
231 pub(crate) runner_id: String,
233 pub(crate) failure_type_code: String,
235 pub(crate) context_kind: String,
238}
239
240impl TestFailure {
241 #[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 #[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 #[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 #[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 let related_info = self.build_related_info();
299
300 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 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 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 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 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(); 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(¤t_span.path);
486 all.iter()
487 .filter(|info| {
488 current_uri.as_ref() != Some(&info.location.uri)
490 || info.location.range != current_span.range
491 })
492 .cloned()
493 .collect()
494}
495
496#[derive(Debug, Clone)]
498pub(crate) struct FileDiagnostics {
499 pub(crate) path: PathBuf,
500 pub(crate) diagnostics: Vec<Diagnostic>,
501}
502
503pub(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#[derive(Debug, Serialize, Clone, Deserialize, Default)]
529
530pub struct Workspaces {
531 pub(crate) map: HashMap<PathBuf, Vec<PathBuf>>,
532}