feat(adapter): add vitest adapter

This commit is contained in:
kbwo 2024-07-15 19:09:09 +09:00
parent 3f88f9b1b6
commit 5ac623bb69
3 changed files with 340 additions and 0 deletions

View file

@ -2,3 +2,4 @@ pub mod cargo_test;
pub mod go;
pub mod jest;
pub mod util;
pub mod vitest;

View file

@ -2,9 +2,13 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use regex::Regex;
use serde::Serialize;
use testing_language_server::error::LSError;
// If the character value is greater than the line length it defaults back to the line length.
pub const MAX_CHAR_LENGTH: u32 = 10000;
/// determine if a particular file is the root of workspace based on whether it is in the same directory
pub fn detect_workspace_from_file(file_path: PathBuf, file_names: &[String]) -> Option<String> {
let parent = file_path.parent();
@ -62,3 +66,8 @@ where
serde_json::to_writer(std::io::stdout(), &value)?;
Ok(())
}
pub fn clean_ansi(input: &str) -> String {
let re = Regex::new(r"\x1B\[([0-9]{1,2}(;[0-9]{1,2})*)?[m|K]").unwrap();
re.replace_all(input, "").to_string()
}

View file

@ -0,0 +1,330 @@
use std::{
collections::HashMap,
fs::{self, File},
};
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use serde_json::Value;
use tempfile::tempdir;
use testing_language_server::{
error::LSError,
spec::{
DiscoverResult, DiscoverResultItem, RunFileTestResult, RunFileTestResultItem, TestItem,
},
};
use tree_sitter::{Point, Query, QueryCursor};
use crate::model::Runner;
use super::util::{clean_ansi, detect_workspaces_from_file_paths, send_stdout, MAX_CHAR_LENGTH};
#[derive(Eq, PartialEq, Hash, Debug)]
pub struct VitestRunner;
fn discover(file_path: &str) -> Result<Vec<TestItem>, LSError> {
let mut parser = tree_sitter::Parser::new();
let mut test_items: Vec<TestItem> = vec![];
parser
.set_language(&tree_sitter_javascript::language())
.expect("Error loading Rust grammar");
let source_code = std::fs::read_to_string(file_path)?;
let tree = parser.parse(&source_code, None).unwrap();
// from https://github.com/marilari88/neotest-vitest/blob/353364aa05b94b09409cbef21b79c97c5564e2ce/lua/neotest-vitest/init.lua#L101
let query_string = r#"
; -- Namespaces --
; Matches: `describe('context')`
((call_expression
function: (identifier) @func_name (#eq? @func_name "describe")
arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function))
)) @namespace.definition
; Matches: `describe.only('context')`
((call_expression
function: (member_expression
object: (identifier) @func_name (#any-of? @func_name "describe")
)
arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function))
)) @namespace.definition
; Matches: `describe.each(['data'])('context')`
((call_expression
function: (call_expression
function: (member_expression
object: (identifier) @func_name (#any-of? @func_name "describe")
)
)
arguments: (arguments (string (string_fragment) @namespace.name) (arrow_function))
)) @namespace.definition
; -- Tests --
; Matches: `test('test') / it('test')`
((call_expression
function: (identifier) @func_name (#any-of? @func_name "it" "test")
arguments: (arguments (string (string_fragment) @test.name) (arrow_function))
)) @test.definition
; Matches: `test.only('test') / it.only('test')`
((call_expression
function: (member_expression
object: (identifier) @func_name (#any-of? @func_name "test" "it")
)
arguments: (arguments (string (string_fragment) @test.name) (arrow_function))
)) @test.definition
; Matches: `test.each(['data'])('test') / it.each(['data'])('test')`
((call_expression
function: (call_expression
function: (member_expression
object: (identifier) @func_name (#any-of? @func_name "it" "test")
)
)
arguments: (arguments (string (string_fragment) @test.name) (arrow_function))
)) @test.definition
"#;
let query = Query::new(&tree_sitter_javascript::language(), query_string)
.expect("Error creating query");
let mut cursor = QueryCursor::new();
cursor.set_byte_range(tree.root_node().byte_range());
let source = source_code.as_bytes();
let matches = cursor.matches(&query, tree.root_node(), source);
for m in matches {
let mut namespace_name = "";
let mut test_start_position = Point::default();
let mut test_end_position = Point::default();
for capture in m.captures {
let capture_name = query.capture_names()[capture.index as usize];
let value = capture.node.utf8_text(source)?;
let start_position = capture.node.start_position();
let end_position = capture.node.end_position();
match capture_name {
"namespace.name" => {
namespace_name = value;
}
"test.definition" => {
test_start_position = start_position;
test_end_position = end_position;
}
"test.name" => {
let test_name = value;
let test_item = TestItem {
id: format!("{}:{}", namespace_name, test_name),
name: test_name.to_string(),
start_position: Range {
start: Position {
line: test_start_position.row as u32,
character: test_start_position.column as u32,
},
end: Position {
line: test_start_position.row as u32,
character: MAX_CHAR_LENGTH,
},
},
end_position: Range {
start: Position {
line: test_end_position.row as u32,
character: 0,
},
end: Position {
line: test_end_position.row as u32,
character: test_end_position.column as u32,
},
},
};
test_items.push(test_item);
test_start_position = Point::default();
test_end_position = Point::default();
}
_ => {}
}
}
}
Ok(test_items)
}
fn parse_diagnostics(
test_result: &str,
file_paths: Vec<String>,
) -> Result<RunFileTestResult, LSError> {
let mut result_map: HashMap<String, Vec<Diagnostic>> = HashMap::new();
let json: Value = serde_json::from_str(test_result)?;
let test_results = json["testResults"].as_array().unwrap();
for test_result in test_results {
let file_path = test_result["name"].as_str().unwrap();
if !file_paths.iter().any(|path| path.contains(file_path)) {
continue;
}
let assertion_results = test_result["assertionResults"].as_array().unwrap();
'assertion: for assertion_result in assertion_results {
let status = assertion_result["status"].as_str().unwrap();
if status != "failed" {
continue 'assertion;
}
let location = assertion_result["location"].as_object().unwrap();
let failure_messages = assertion_result["failureMessages"].as_array().unwrap();
let line = location["line"].as_u64().unwrap() - 1;
failure_messages.iter().for_each(|message| {
let message = clean_ansi(message.as_str().unwrap());
let diagnostic = Diagnostic {
range: lsp_types::Range {
start: lsp_types::Position {
line: line as u32,
// Line and column number is slightly incorrect.
// ref:
// Bug in json reporter line number? · vitest-dev/vitest · Discussion #5350
// https://github.com/vitest-dev/vitest/discussions/5350
// Currently, The row numbers are from the parse result, the column numbers are 0 and MAX_CHAR_LENGTH is hard-coded.
character: 0,
},
end: lsp_types::Position {
line: line as u32,
character: MAX_CHAR_LENGTH,
},
},
message,
severity: Some(DiagnosticSeverity::ERROR),
..Diagnostic::default()
};
result_map
.entry(file_path.to_string())
.or_default()
.push(diagnostic);
})
}
}
Ok(result_map
.into_iter()
.map(|(path, diagnostics)| RunFileTestResultItem { path, diagnostics })
.collect())
}
impl Runner for VitestRunner {
fn disover(&self, args: testing_language_server::spec::DiscoverArgs) -> Result<(), LSError> {
let file_paths = args.file_paths;
let mut discover_results: DiscoverResult = vec![];
for file_path in file_paths {
let tests = discover(&file_path)?;
discover_results.push(DiscoverResultItem {
tests,
path: file_path,
});
}
send_stdout(&discover_results)?;
Ok(())
}
fn run_file_test(
&self,
args: testing_language_server::spec::RunFileTestArgs,
) -> Result<(), LSError> {
let file_paths = args.file_paths;
let workspace_root = args.workspace;
let tempdir = tempdir().unwrap();
let tempdir_path = tempdir.path();
let tempfile_path = tempdir_path.join("vitest.json");
let tempfile = File::create(&tempfile_path)?;
let tempfile_path = tempfile_path.to_str().unwrap();
std::process::Command::new("vitest")
.current_dir(&workspace_root)
.args([
"--watch=false",
"--reporter=json",
"--outputFile=",
tempfile_path,
])
.output()
.unwrap();
let test_result = fs::read_to_string(tempfile_path)?;
let diagnostics: RunFileTestResult = parse_diagnostics(&test_result, file_paths)?;
send_stdout(&diagnostics)?;
drop(tempfile);
let _ = tempdir.close();
Ok(())
}
fn detect_workspaces(
&self,
args: testing_language_server::spec::DetectWorkspaceArgs,
) -> Result<(), LSError> {
send_stdout(&detect_workspaces_from_file_paths(
&args.file_paths,
&[
"package.json".to_string(),
"vitest.config.ts".to_string(),
"vitest.config.js".to_string(),
"vite.config.ts".to_string(),
"vite.config.js".to_string(),
"vitest.config.mts".to_string(),
"vitest.config.mjs".to_string(),
"vite.config.mts".to_string(),
"vite.config.mjs".to_string(),
],
))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use lsp_types::{Position, Range};
use super::*;
#[test]
fn test_discover() {
let file_path = "../../test_proj/vitest/basic.test.ts";
let test_items = discover(file_path).unwrap();
assert_eq!(test_items.len(), 2);
assert_eq!(
test_items,
vec![
TestItem {
id: String::from(":pass"),
name: String::from("pass"),
start_position: Range {
start: Position {
line: 4,
character: 2
},
end: Position {
line: 4,
character: MAX_CHAR_LENGTH
}
},
end_position: Range {
start: Position {
line: 6,
character: 0
},
end: Position {
line: 6,
character: 4
}
}
},
TestItem {
id: String::from(":fail"),
name: String::from("fail"),
start_position: Range {
start: Position {
line: 8,
character: 2
},
end: Position {
line: 8,
character: MAX_CHAR_LENGTH
}
},
end_position: Range {
start: Position {
line: 10,
character: 0
},
end: Position {
line: 10,
character: 4
}
}
}
]
)
}
}