Merge pull request #20 from kbwo/feat/cargo-nextest

feat(adapter): add adapter for cargo-nextest
This commit is contained in:
Kodai Kabasawa 2024-07-16 00:31:13 +09:00 committed by GitHub
commit 70c0fd4a2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 392 additions and 172 deletions

View file

@ -1,3 +1,4 @@
use crate::runner::cargo_nextest::CargoNextestRunner;
use crate::runner::cargo_test::CargoTestRunner;
use crate::runner::go::GoTestRunner;
use crate::runner::vitest::VitestRunner;
@ -12,6 +13,7 @@ use crate::runner::jest::JestRunner;
#[derive(Debug, Eq, PartialEq)]
pub enum AvailableTestKind {
CargoTest(CargoTestRunner),
CargoNextest(CargoNextestRunner),
Jest(JestRunner),
Vitest(VitestRunner),
GoTest(GoTestRunner),
@ -20,6 +22,7 @@ impl Runner for AvailableTestKind {
fn disover(&self, args: DiscoverArgs) -> Result<(), LSError> {
match self {
AvailableTestKind::CargoTest(runner) => runner.disover(args),
AvailableTestKind::CargoNextest(runner) => runner.disover(args),
AvailableTestKind::Jest(runner) => runner.disover(args),
AvailableTestKind::GoTest(runner) => runner.disover(args),
AvailableTestKind::Vitest(runner) => runner.disover(args),
@ -29,6 +32,7 @@ impl Runner for AvailableTestKind {
fn run_file_test(&self, args: RunFileTestArgs) -> Result<(), LSError> {
match self {
AvailableTestKind::CargoTest(runner) => runner.run_file_test(args),
AvailableTestKind::CargoNextest(runner) => runner.run_file_test(args),
AvailableTestKind::Jest(runner) => runner.run_file_test(args),
AvailableTestKind::GoTest(runner) => runner.run_file_test(args),
AvailableTestKind::Vitest(runner) => runner.run_file_test(args),
@ -38,6 +42,7 @@ impl Runner for AvailableTestKind {
fn detect_workspaces(&self, args: DetectWorkspaceArgs) -> Result<(), LSError> {
match self {
AvailableTestKind::CargoTest(runner) => runner.detect_workspaces(args),
AvailableTestKind::CargoNextest(runner) => runner.detect_workspaces(args),
AvailableTestKind::Jest(runner) => runner.detect_workspaces(args),
AvailableTestKind::GoTest(runner) => runner.detect_workspaces(args),
AvailableTestKind::Vitest(runner) => runner.detect_workspaces(args),
@ -51,6 +56,7 @@ impl FromStr for AvailableTestKind {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"cargo-test" => Ok(AvailableTestKind::CargoTest(CargoTestRunner)),
"cargo-nextest" => Ok(AvailableTestKind::CargoNextest(CargoNextestRunner)),
"jest" => Ok(AvailableTestKind::Jest(JestRunner)),
"go-test" => Ok(AvailableTestKind::GoTest(GoTestRunner)),
"vitest" => Ok(AvailableTestKind::Vitest(VitestRunner)),

View file

@ -0,0 +1,197 @@
use crate::runner::util::send_stdout;
use std::path::PathBuf;
use std::process::Output;
use std::str::FromStr;
use testing_language_server::error::LSError;
use testing_language_server::spec::DetectWorkspaceResult;
use testing_language_server::spec::RunFileTestResult;
use testing_language_server::spec::DiscoverResult;
use testing_language_server::spec::DiscoverResultItem;
use crate::model::Runner;
use super::util::detect_workspaces_from_file_paths;
use super::util::discover_rust_tests;
use super::util::parse_cargo_diagnostics;
fn parse_diagnostics(
contents: &str,
workspace_root: PathBuf,
file_paths: &[String],
) -> RunFileTestResult {
parse_cargo_diagnostics(contents, workspace_root, file_paths)
}
fn detect_workspaces(file_paths: &[String]) -> DetectWorkspaceResult {
detect_workspaces_from_file_paths(file_paths, &["Cargo.toml".to_string()])
}
#[derive(Eq, PartialEq, Hash, Debug)]
pub struct CargoNextestRunner;
impl Runner for CargoNextestRunner {
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_rust_tests(&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 tests = file_paths
.iter()
.map(|path| {
discover_rust_tests(path).map(|test_items| {
test_items
.into_iter()
.map(|item| item.id)
.collect::<Vec<String>>()
})
})
.filter_map(Result::ok)
.flatten()
.collect::<Vec<_>>();
let workspace_root = args.workspace;
let test_result = std::process::Command::new("cargo")
.current_dir(&workspace_root)
.arg("nextest")
.arg("run")
.arg("--workspace")
.arg("--no-fail-fast")
.args(args.extra)
.arg("--")
.args(tests)
.output()
.unwrap();
let Output {
stdout,
stderr,
status,
..
} = test_result;
let unexpected_status_code = status.code().map(|code| code != 100);
if stdout.is_empty() && !stderr.is_empty() && unexpected_status_code.unwrap_or(false) {
return Err(LSError::Adapter(String::from_utf8(stderr).unwrap()));
}
let test_result = String::from_utf8(stderr)?;
let diagnostics: RunFileTestResult = parse_diagnostics(
&test_result,
PathBuf::from_str(&workspace_root).unwrap(),
&file_paths,
);
send_stdout(&diagnostics)?;
Ok(())
}
fn detect_workspaces(
&self,
args: testing_language_server::spec::DetectWorkspaceArgs,
) -> Result<(), LSError> {
let file_paths = args.file_paths;
let detect_result = detect_workspaces(&file_paths);
send_stdout(&detect_result)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use testing_language_server::spec::RunFileTestResultItem;
use crate::runner::util::MAX_CHAR_LENGTH;
use super::*;
#[test]
fn parse_test_results() {
let fixture = r#"
running 1 test
test rocks::dependency::tests::parse_dependency ... FAILED
failures:
Finished test [unoptimized + debuginfo] target(s) in 0.12s
Starting 1 test across 2 binaries (17 skipped)
FAIL [ 0.004s] rocks-lib rocks::dependency::tests::parse_dependency
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 17 filtered out; finis
hed in 0.00s
--- STDERR: rocks-lib rocks::dependency::tests::parse_dependency ---
thread 'rocks::dependency::tests::parse_dependency' panicked at rocks-lib/src/rocks/dependency.rs:86:64:
called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number
Location:
rocks-lib/src/rocks/dependency.rs:62:22
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
"#;
let file_paths =
vec!["/home/example/projects/rocks-lib/src/rocks/dependency.rs".to_string()];
let diagnostics: RunFileTestResult = parse_diagnostics(
fixture,
PathBuf::from_str("/home/example/projects").unwrap(),
&file_paths,
);
let message = r#"called `Result::unwrap()` on an `Err` value: unexpected end of input while parsing min or version number
Location:
rocks-lib/src/rocks/dependency.rs:62:22
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
"#;
assert_eq!(
diagnostics,
vec![RunFileTestResultItem {
path: file_paths.first().unwrap().to_owned(),
diagnostics: vec![Diagnostic {
range: Range {
start: Position {
line: 85,
character: 63
},
end: Position {
line: 85,
character: MAX_CHAR_LENGTH
}
},
message: message.to_string(),
severity: Some(DiagnosticSeverity::ERROR),
..Diagnostic::default()
}]
}]
)
}
#[test]
fn test_discover() {
let file_path = "../../demo/rust/src/lib.rs";
discover_rust_tests(file_path).unwrap();
}
#[test]
fn test_detect_workspaces() {
let current_dir = std::env::current_dir().unwrap();
let librs = current_dir.join("src/lib.rs");
let mainrs = current_dir.join("src/main.rs");
let absolute_path_of_demo = current_dir.join("../../demo/rust");
let demo_librs = absolute_path_of_demo.join("src/lib.rs");
let file_paths: Vec<String> = [librs, mainrs, demo_librs]
.iter()
.map(|file_path| file_path.to_str().unwrap().to_string())
.collect();
let workspaces = detect_workspaces(&file_paths);
assert_eq!(workspaces.len(), 2);
assert!(workspaces.contains_key(absolute_path_of_demo.to_str().unwrap()));
assert!(workspaces.contains_key(current_dir.to_str().unwrap()));
}
}

View file

@ -1,191 +1,28 @@
use crate::runner::util::send_stdout;
use lsp_types::DiagnosticSeverity;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Output;
use std::str::FromStr;
use testing_language_server::error::LSError;
use testing_language_server::spec::DetectWorkspaceResult;
use testing_language_server::spec::RunFileTestResult;
use testing_language_server::spec::TestItem;
use tree_sitter::Point;
use tree_sitter::Query;
use tree_sitter::QueryCursor;
use lsp_types::{Diagnostic, Position, Range};
use regex::Regex;
use testing_language_server::spec::DiscoverResult;
use testing_language_server::spec::DiscoverResultItem;
use testing_language_server::spec::RunFileTestResultItem;
use crate::model::Runner;
use super::util::detect_workspaces_from_file_paths;
use super::util::discover_rust_tests;
use super::util::parse_cargo_diagnostics;
// If the character value is greater than the line length it defaults back to the line length.
const MAX_CHAR_LENGTH: u32 = 10000;
fn parse_diagnostics(
contents: &str,
workspace_root: PathBuf,
file_paths: &[String],
) -> RunFileTestResult {
let contents = contents.replace("\r\n", "\n");
let lines = contents.lines();
let mut result_map: HashMap<String, Vec<Diagnostic>> = HashMap::new();
for (i, line) in lines.clone().enumerate() {
let re = Regex::new(r"thread '([^']+)' panicked at ([^:]+):(\d+):(\d+):").unwrap();
if let Some(m) = re.captures(line) {
let mut message = String::new();
let file = m.get(2).unwrap().as_str().to_string();
if let Some(file_path) = file_paths
.iter()
.find(|path| path.contains(workspace_root.join(&file).to_str().unwrap()))
{
let lnum = m.get(3).unwrap().as_str().parse::<u32>().unwrap() - 1;
let col = m.get(4).unwrap().as_str().parse::<u32>().unwrap() - 1;
let mut next_i = i + 1;
while next_i < lines.clone().count()
&& !lines.clone().nth(next_i).unwrap().is_empty()
{
message = format!("{}{}\n", message, lines.clone().nth(next_i).unwrap());
next_i += 1;
}
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: lnum,
character: col,
},
end: Position {
line: lnum,
character: MAX_CHAR_LENGTH,
},
},
message,
severity: Some(DiagnosticSeverity::ERROR),
..Diagnostic::default()
};
result_map
.entry(file_path.to_string())
.or_default()
.push(diagnostic);
} else {
continue;
}
}
}
result_map
.into_iter()
.map(|(path, diagnostics)| RunFileTestResultItem { path, diagnostics })
.collect()
}
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_rust::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/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/lua/neotest-rust/init.lua#L167
// license: https://github.com/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/LICENSE
let query_string = r#"
(
(attribute_item
[
(attribute
(identifier) @macro_name
)
(attribute
[
(identifier) @macro_name
(scoped_identifier
name: (identifier) @macro_name
)
]
)
]
)
[
(attribute_item
(attribute
(identifier)
)
)
(line_comment)
]*
.
(function_item
name: (identifier) @test.name
) @test.definition
(#any-of? @macro_name "test" "rstest" "case")
)
(mod_item name: (identifier) @namespace.name)? @namespace.definition
"#;
let query =
Query::new(&tree_sitter_rust::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)
parse_cargo_diagnostics(contents, workspace_root, file_paths)
}
fn detect_workspaces(file_paths: &[String]) -> DetectWorkspaceResult {
@ -201,7 +38,7 @@ impl Runner for CargoTestRunner {
let mut discover_results: DiscoverResult = vec![];
for file_path in file_paths {
let tests = discover(&file_path)?;
let tests = discover_rust_tests(&file_path)?;
discover_results.push(DiscoverResultItem {
tests,
path: file_path,
@ -219,7 +56,7 @@ impl Runner for CargoTestRunner {
let tests = file_paths
.iter()
.map(|path| {
discover(path).map(|test_items| {
discover_rust_tests(path).map(|test_items| {
test_items
.into_iter()
.map(|item| item.id)
@ -265,6 +102,11 @@ impl Runner for CargoTestRunner {
#[cfg(test)]
mod tests {
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use testing_language_server::spec::RunFileTestResultItem;
use crate::runner::util::MAX_CHAR_LENGTH;
use super::*;
#[test]
@ -325,7 +167,7 @@ note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
#[test]
fn test_discover() {
let file_path = "../../demo/rust/src/lib.rs";
discover(file_path).unwrap();
discover_rust_tests(file_path).unwrap();
}
#[test]

View file

@ -1,3 +1,4 @@
pub mod cargo_nextest;
pub mod cargo_test;
pub mod go;
pub mod jest;

View file

@ -2,9 +2,12 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::str::FromStr;
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
use regex::Regex;
use serde::Serialize;
use testing_language_server::error::LSError;
use testing_language_server::spec::{RunFileTestResultItem, TestItem};
use testing_language_server::{error::LSError, spec::RunFileTestResult};
use tree_sitter::{Point, Query, QueryCursor};
// If the character value is greater than the line length it defaults back to the line length.
pub const MAX_CHAR_LENGTH: u32 = 10000;
@ -71,3 +74,166 @@ 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()
}
pub fn discover_rust_tests(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_rust::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/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/lua/neotest-rust/init.lua#L167
// license: https://github.com/rouge8/neotest-rust/blob/0418811e1e3499b2501593f2e131d02f5e6823d4/LICENSE
let query_string = r#"
(
(attribute_item
[
(attribute
(identifier) @macro_name
)
(attribute
[
(identifier) @macro_name
(scoped_identifier
name: (identifier) @macro_name
)
]
)
]
)
[
(attribute_item
(attribute
(identifier)
)
)
(line_comment)
]*
.
(function_item
name: (identifier) @test.name
) @test.definition
(#any-of? @macro_name "test" "rstest" "case")
)
(mod_item name: (identifier) @namespace.name)? @namespace.definition
"#;
let query =
Query::new(&tree_sitter_rust::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)
}
pub fn parse_cargo_diagnostics(
contents: &str,
workspace_root: PathBuf,
file_paths: &[String],
) -> RunFileTestResult {
let contents = contents.replace("\r\n", "\n");
let lines = contents.lines();
let mut result_map: HashMap<String, Vec<Diagnostic>> = HashMap::new();
for (i, line) in lines.clone().enumerate() {
let re = Regex::new(r"thread '([^']+)' panicked at ([^:]+):(\d+):(\d+):").unwrap();
if let Some(m) = re.captures(line) {
let mut message = String::new();
let file = m.get(2).unwrap().as_str().to_string();
if let Some(file_path) = file_paths
.iter()
.find(|path| path.contains(workspace_root.join(&file).to_str().unwrap()))
{
let lnum = m.get(3).unwrap().as_str().parse::<u32>().unwrap() - 1;
let col = m.get(4).unwrap().as_str().parse::<u32>().unwrap() - 1;
let mut next_i = i + 1;
while next_i < lines.clone().count()
&& !lines.clone().nth(next_i).unwrap().is_empty()
{
message = format!("{}{}\n", message, lines.clone().nth(next_i).unwrap());
next_i += 1;
}
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: lnum,
character: col,
},
end: Position {
line: lnum,
character: MAX_CHAR_LENGTH,
},
},
message,
severity: Some(DiagnosticSeverity::ERROR),
..Diagnostic::default()
};
result_map
.entry(file_path.to_string())
.or_default()
.push(diagnostic);
} else {
continue;
}
}
}
result_map
.into_iter()
.map(|(path, diagnostics)| RunFileTestResultItem { path, diagnostics })
.collect()
}

View file

@ -6,10 +6,18 @@
"filetypes": ["rust", "javascript", "go", "typescript"],
"initializationOptions": {
"adapterCommand": {
"rust": [
// "cargo-test": [
// {
// "path": "testing-ls-adapter",
// "extra_args": ["--test-kind=cargo-test"],
// "include_patterns": ["/**/src/**/*.rs"],
// "exclude_patterns": ["/**/target/**"]
// }
// ],
"cargo-nextest": [
{
"path": "testing-ls-adapter",
"extra_args": ["--test-kind=cargo-test"],
"extra_args": ["--test-kind=cargo-nextest"],
"include_patterns": ["/**/src/**/*.rs"],
"exclude_patterns": ["/**/target/**"]
}