mirror of
https://github.com/kbwo/testing-language-server.git
synced 2025-08-03 15:18:16 +00:00
feat(adapter): add vitest adapter
This commit is contained in:
parent
3f88f9b1b6
commit
5ac623bb69
3 changed files with 340 additions and 0 deletions
|
@ -2,3 +2,4 @@ pub mod cargo_test;
|
|||
pub mod go;
|
||||
pub mod jest;
|
||||
pub mod util;
|
||||
pub mod vitest;
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
330
crates/adapter/src/runner/vitest.rs
Normal file
330
crates/adapter/src/runner/vitest.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue