Merge pull request #26 from kbwo/feat/deno

adapter for deno
This commit is contained in:
Kodai Kabasawa 2024-07-21 16:02:33 +09:00 committed by GitHub
commit bb1a163f98
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 530 additions and 4 deletions

4
Cargo.lock generated
View file

@ -89,9 +89,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.0.96"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "065a29261d53ba54260972629f9ca6bffa69bac13cd1fed61420f7fa68b9f8bd"
checksum = "066fce287b1d4eafef758e89e09d724a24808a9196fe9756b8ca90e86d0719a2"
[[package]]
name = "cfg-if"

View file

@ -1,5 +1,6 @@
use crate::runner::cargo_nextest::CargoNextestRunner;
use crate::runner::cargo_test::CargoTestRunner;
use crate::runner::deno::DenoRunner;
use crate::runner::go::GoTestRunner;
use crate::runner::vitest::VitestRunner;
use std::str::FromStr;
@ -16,6 +17,7 @@ pub enum AvailableTestKind {
CargoNextest(CargoNextestRunner),
Jest(JestRunner),
Vitest(VitestRunner),
Deno(DenoRunner),
GoTest(GoTestRunner),
}
impl Runner for AvailableTestKind {
@ -24,6 +26,7 @@ impl Runner for AvailableTestKind {
AvailableTestKind::CargoTest(runner) => runner.disover(args),
AvailableTestKind::CargoNextest(runner) => runner.disover(args),
AvailableTestKind::Jest(runner) => runner.disover(args),
AvailableTestKind::Deno(runner) => runner.disover(args),
AvailableTestKind::GoTest(runner) => runner.disover(args),
AvailableTestKind::Vitest(runner) => runner.disover(args),
}
@ -34,6 +37,7 @@ impl Runner for AvailableTestKind {
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::Deno(runner) => runner.run_file_test(args),
AvailableTestKind::GoTest(runner) => runner.run_file_test(args),
AvailableTestKind::Vitest(runner) => runner.run_file_test(args),
}
@ -44,6 +48,7 @@ impl Runner for AvailableTestKind {
AvailableTestKind::CargoTest(runner) => runner.detect_workspaces(args),
AvailableTestKind::CargoNextest(runner) => runner.detect_workspaces(args),
AvailableTestKind::Jest(runner) => runner.detect_workspaces(args),
AvailableTestKind::Deno(runner) => runner.detect_workspaces(args),
AvailableTestKind::GoTest(runner) => runner.detect_workspaces(args),
AvailableTestKind::Vitest(runner) => runner.detect_workspaces(args),
}
@ -60,6 +65,7 @@ impl FromStr for AvailableTestKind {
"jest" => Ok(AvailableTestKind::Jest(JestRunner)),
"go-test" => Ok(AvailableTestKind::GoTest(GoTestRunner)),
"vitest" => Ok(AvailableTestKind::Vitest(VitestRunner)),
"deno" => Ok(AvailableTestKind::Deno(DenoRunner)),
_ => Err(anyhow::anyhow!("Unknown test kind: {}", s)),
}
}

View file

@ -0,0 +1,398 @@
use crate::runner::util::resolve_path;
use crate::runner::util::send_stdout;
use lsp_types::Diagnostic;
use lsp_types::DiagnosticSeverity;
use lsp_types::Position;
use lsp_types::Range;
use regex::Regex;
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::DiscoverResult;
use testing_language_server::spec::DiscoverResultItem;
use testing_language_server::spec::RunFileTestResult;
use testing_language_server::spec::RunFileTestResultItem;
use testing_language_server::spec::TestItem;
use tree_sitter::Point;
use tree_sitter::Query;
use tree_sitter::QueryCursor;
use crate::model::Runner;
use super::util::clean_ansi;
use super::util::detect_workspaces_from_file_paths;
use super::util::MAX_CHAR_LENGTH;
fn get_position_from_output(line: &str) -> Option<(String, u32, u32)> {
let re = Regex::new(r"=> (?P<file>.*):(?P<line>\d+):(?P<column>\d+)").unwrap();
if let Some(captures) = re.captures(line) {
let file = captures.name("file").unwrap().as_str().to_string();
let line = captures.name("line").unwrap().as_str().parse().unwrap();
let column = captures.name("column").unwrap().as_str().parse().unwrap();
Some((file, line, column))
} else {
None
}
}
fn parse_diagnostics(
contents: &str,
workspace_root: PathBuf,
file_paths: &[String],
) -> Result<RunFileTestResult, LSError> {
let contents = clean_ansi(&contents.replace("\r\n", "\n"));
let lines = contents.lines();
let mut result_map: HashMap<String, Vec<Diagnostic>> = HashMap::new();
let mut file_name: Option<String> = None;
let mut lnum: Option<u32> = None;
let mut message = String::new();
let mut error_exists = false;
for line in lines {
if line.contains("ERRORS") {
error_exists = true;
} else if !error_exists {
continue;
}
if let Some(position) = get_position_from_output(line) {
if file_name.is_some() {
let diagnostic = Diagnostic {
range: Range {
start: Position {
line: lnum.unwrap(),
character: 1,
},
end: Position {
line: lnum.unwrap(),
character: MAX_CHAR_LENGTH,
},
},
message: message.clone(),
severity: Some(DiagnosticSeverity::ERROR),
..Diagnostic::default()
};
let file_path = resolve_path(&workspace_root, file_name.as_ref().unwrap())
.to_str()
.unwrap()
.to_string();
if file_paths.contains(&file_path) {
result_map.entry(file_path).or_default().push(diagnostic);
}
}
file_name = Some(position.0);
lnum = Some(position.1);
} else {
message += line;
}
}
Ok(result_map
.into_iter()
.map(|(path, diagnostics)| RunFileTestResultItem { path, diagnostics })
.collect())
}
fn detect_workspaces(file_paths: Vec<String>) -> DetectWorkspaceResult {
detect_workspaces_from_file_paths(&file_paths, &["deno.json".to_string()])
}
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 JavaScript grammar");
let source_code = std::fs::read_to_string(file_path)?;
let tree = parser.parse(&source_code, None).unwrap();
// from https://github.com/MarkEmmons/neotest-deno/blob/7136b9342aeecb675c7c16a0bde327d7fcb00a1c/lua/neotest-deno/init.lua#L93
// license: https://github.com/MarkEmmons/neotest-deno/blob/main/LICENSE
let query_string = r#"
;; Deno.test
(call_expression
function: (member_expression) @func_name (#match? @func_name "^Deno.test$")
arguments: [
(arguments ((string) @test.name . (arrow_function)))
(arguments . (function_expression name: (identifier) @test.name))
(arguments . (object(pair
key: (property_identifier) @key (#match? @key "^name$")
value: (string) @test.name
)))
(arguments ((string) @test.name . (object) . (arrow_function)))
(arguments (object) . (function_expression name: (identifier) @test.name))
]
) @test.definition
;; BDD describe - nested
(call_expression
function: (identifier) @func_name (#match? @func_name "^describe$")
arguments: [
(arguments ((string) @namespace.name . (arrow_function)))
(arguments ((string) @namespace.name . (function_expression)))
]
) @namespace.definition
;; BDD describe - flat
(variable_declarator
name: (identifier) @namespace.id
value: (call_expression
function: (identifier) @func_name (#match? @func_name "^describe")
arguments: [
(arguments ((string) @namespace.name))
(arguments (object (pair
key: (property_identifier) @key (#match? @key "^name$")
value: (string) @namespace.name
)))
]
)
) @namespace.definition
;; BDD it
(call_expression
function: (identifier) @func_name (#match? @func_name "^it$")
arguments: [
(arguments ((string) @test.name . (arrow_function)))
(arguments ((string) @test.name . (function_expression)))
]
) @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 {
eprintln!("DEBUGPRINT[3]: deno.rs:170: m={:#?}", m);
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)
}
#[derive(Eq, PartialEq, Debug)]
pub struct DenoRunner;
impl Runner for DenoRunner {
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 {
discover_results.push(DiscoverResultItem {
tests: discover(&file_path)?,
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 = args.workspace;
let output = std::process::Command::new("deno")
.current_dir(&workspace)
.args(["test", "--no-prompt"])
.args(&file_paths)
.output()
.unwrap();
let Output { stdout, stderr, .. } = output;
if stdout.is_empty() {
return Err(LSError::Adapter(String::from_utf8(stderr).unwrap()));
}
let test_result = String::from_utf8(stdout)?;
let diagnostics: RunFileTestResult = parse_diagnostics(
&test_result,
PathBuf::from_str(&workspace).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 std::env::current_dir;
use super::*;
#[test]
fn test_parse_diagnostics() {
let test_result = std::env::current_dir()
.unwrap()
.join("../../demo/deno/output.txt");
let test_result = std::fs::read_to_string(test_result).unwrap();
let workspace = PathBuf::from_str("/home/demo/test/dneo/").unwrap();
let target_file_path = "/home/demo/test/dneo/main_test.ts";
let diagnostics =
parse_diagnostics(&test_result, workspace, &[target_file_path.to_string()]).unwrap();
assert_eq!(diagnostics.len(), 1);
}
#[test]
fn test_detect_workspace() {
let current_dir = std::env::current_dir().unwrap();
let absolute_path_of_demo = current_dir.join("../../demo/deno");
let test_file = absolute_path_of_demo.join("main.test.ts");
let file_paths: Vec<String> = [test_file]
.iter()
.map(|file_path| file_path.to_str().unwrap().to_string())
.collect();
let detect_result = detect_workspaces(file_paths);
assert_eq!(detect_result.len(), 1);
detect_result.iter().for_each(|(workspace, _)| {
assert_eq!(workspace, absolute_path_of_demo.to_str().unwrap());
});
}
#[test]
fn test_discover() {
let file_path = current_dir().unwrap().join("../../demo/deno/main_test.ts");
let file_path = file_path.to_str().unwrap();
let test_items = discover(file_path).unwrap();
assert_eq!(test_items.len(), 3);
assert_eq!(
test_items,
vec![
TestItem {
id: String::from(":addTest"),
name: String::from("addTest"),
start_position: Range {
start: Position {
line: 7,
character: 0
},
end: Position {
line: 7,
character: 10000
}
},
end_position: Range {
start: Position {
line: 9,
character: 0
},
end: Position {
line: 9,
character: 2
}
}
},
TestItem {
id: String::from(":fail1"),
name: String::from("fail1"),
start_position: Range {
start: Position {
line: 11,
character: 0
},
end: Position {
line: 11,
character: 10000
}
},
end_position: Range {
start: Position {
line: 13,
character: 0
},
end: Position {
line: 13,
character: 2
}
}
},
TestItem {
id: String::from(":fail1"),
name: String::from("fail1"),
start_position: Range {
start: Position {
line: 15,
character: 0
},
end: Position {
line: 15,
character: 10000
}
},
end_position: Range {
start: Position {
line: 17,
character: 0
},
end: Position {
line: 17,
character: 2
}
}
}
]
)
}
}

View file

@ -1,5 +1,6 @@
pub mod cargo_nextest;
pub mod cargo_test;
pub mod deno;
pub mod go;
pub mod jest;
pub mod util;

View file

@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use lsp_types::{Diagnostic, DiagnosticSeverity, Position, Range};
@ -237,3 +237,27 @@ pub fn parse_cargo_diagnostics(
.map(|(path, diagnostics)| RunFileTestResultItem { path, diagnostics })
.collect()
}
/// remove this function because duplicate implementation
pub fn resolve_path(base_dir: &Path, relative_path: &str) -> PathBuf {
let absolute = if Path::new(relative_path).is_absolute() {
PathBuf::from(relative_path)
} else {
base_dir.join(relative_path)
};
let mut components = Vec::new();
for component in absolute.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::Normal(_) | std::path::Component::RootDir => {
components.push(component);
}
_ => {}
}
}
PathBuf::from_iter(components)
}

View file

@ -41,6 +41,14 @@
"exclude_patterns": ["/vitest/**/node_modules/**/*"]
}
],
"deno": [
{
"path": "testing-ls-adapter",
"extra_args": ["--test-kind=deno"],
"include_patterns": ["/deno/*.ts"],
"exclude_patterns": []
}
],
"go": [
{
"path": "testing-ls-adapter",
@ -52,5 +60,6 @@
}
}
}
}
},
"deno.enable": true
}

5
demo/deno/deno.json Normal file
View file

@ -0,0 +1,5 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
}
}

21
demo/deno/deno.lock generated Normal file
View file

@ -0,0 +1,21 @@
{
"version": "3",
"packages": {
"specifiers": {
"jsr:@std/assert": "jsr:@std/assert@1.0.0",
"jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1"
},
"jsr": {
"@std/assert@1.0.0": {
"integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008",
"dependencies": [
"jsr:@std/internal@^1.0.1"
]
},
"@std/internal@1.0.1": {
"integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6"
}
}
},
"remote": {}
}

8
demo/deno/main.ts Normal file
View file

@ -0,0 +1,8 @@
export function add(a: number, b: number): number {
return a + b;
}
// Learn more at https://deno.land/manual/examples/module_metadata#concepts
if (import.meta.main) {
console.log("Add 2 + 3 =", add(2, 3));
}

18
demo/deno/main_test.ts Normal file
View file

@ -0,0 +1,18 @@
import { assert, assertEquals } from "jsr:@std/assert";
import { add } from "./main.ts";
const throwFn = () => {
throw new Error("error");
};
Deno.test(function addTest() {
assertEquals(add(2, 3), 5);
});
Deno.test(function fail1() {
assertEquals(add(2, 5), 5);
});
Deno.test(function fail1() {
assert(throwFn());
});

36
demo/deno/output.txt Normal file
View file

@ -0,0 +1,36 @@
running 3 tests from ./main_test.ts
addTest ... ok (0ms)
fail1 ... FAILED (1ms)
fail1 ... FAILED (0ms)
 ERRORS 
fail1 => ./main_test.ts:12:6
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- 7
+ 5
throw new AssertionError(message);
 ^
at assertEquals (https://jsr.io/@std/assert/1.0.0/equals.ts:47:9)
at fail1 (file:///home/demo/test/dneo/main_test.ts:13:3)
fail1 => ./main_test.ts:16:6
error: Error: error
throw new Error("error");
 ^
at throwFn (file:///home/demo/test/dneo/main_test.ts:5:9)
at fail1 (file:///home/demo/test/dneo/main_test.ts:17:10)
 FAILURES 
fail1 => ./main_test.ts:12:6
fail1 => ./main_test.ts:16:6
FAILED | 1 passed | 2 failed (3ms)