Add test explorer

This commit is contained in:
hkalbasi 2024-03-01 13:40:29 +03:30
parent 916914418a
commit 44be2432f5
19 changed files with 1083 additions and 172 deletions

View file

@ -158,6 +158,7 @@ dashmap = { version = "=5.5.3", features = ["raw-api"] }
[workspace.lints.rust]
rust_2018_idioms = "warn"
unused_lifetimes = "warn"
unreachable_pub = "warn"
semicolon_in_expressions_from_macros = "warn"
[workspace.lints.clippy]

View file

@ -0,0 +1,156 @@
//! Utilities for running a cargo command like `cargo check` or `cargo test` in a separate thread and
//! parse its stdout/stderr.
use std::{
ffi::OsString,
fmt, io,
path::PathBuf,
process::{ChildStderr, ChildStdout, Command, Stdio},
};
use command_group::{CommandGroup, GroupChild};
use crossbeam_channel::{unbounded, Receiver, Sender};
use stdx::process::streaming_output;
/// Cargo output is structured as a one JSON per line. This trait abstracts parsing one line of
/// cargo output into a Rust data type.
pub(crate) trait ParseFromLine: Sized + Send + 'static {
fn from_line(line: &str, error: &mut String) -> Option<Self>;
fn from_eof() -> Option<Self>;
}
struct CargoActor<T> {
sender: Sender<T>,
stdout: ChildStdout,
stderr: ChildStderr,
}
impl<T: ParseFromLine> CargoActor<T> {
fn new(sender: Sender<T>, stdout: ChildStdout, stderr: ChildStderr) -> Self {
CargoActor { sender, stdout, stderr }
}
fn run(self) -> io::Result<(bool, String)> {
// We manually read a line at a time, instead of using serde's
// stream deserializers, because the deserializer cannot recover
// from an error, resulting in it getting stuck, because we try to
// be resilient against failures.
//
// Because cargo only outputs one JSON object per line, we can
// simply skip a line if it doesn't parse, which just ignores any
// erroneous output.
let mut stdout_errors = String::new();
let mut stderr_errors = String::new();
let mut read_at_least_one_stdout_message = false;
let mut read_at_least_one_stderr_message = false;
let process_line = |line: &str, error: &mut String| {
// Try to deserialize a message from Cargo or Rustc.
if let Some(t) = T::from_line(line, error) {
self.sender.send(t).unwrap();
true
} else {
false
}
};
let output = streaming_output(
self.stdout,
self.stderr,
&mut |line| {
if process_line(line, &mut stdout_errors) {
read_at_least_one_stdout_message = true;
}
},
&mut |line| {
if process_line(line, &mut stderr_errors) {
read_at_least_one_stderr_message = true;
}
},
&mut || {
if let Some(t) = T::from_eof() {
self.sender.send(t).unwrap();
}
},
);
let read_at_least_one_message =
read_at_least_one_stdout_message || read_at_least_one_stderr_message;
let mut error = stdout_errors;
error.push_str(&stderr_errors);
match output {
Ok(_) => Ok((read_at_least_one_message, error)),
Err(e) => Err(io::Error::new(e.kind(), format!("{e:?}: {error}"))),
}
}
}
struct JodGroupChild(GroupChild);
impl Drop for JodGroupChild {
fn drop(&mut self) {
_ = self.0.kill();
_ = self.0.wait();
}
}
/// A handle to a cargo process used for fly-checking.
pub(crate) struct CommandHandle<T> {
/// The handle to the actual cargo process. As we cannot cancel directly from with
/// a read syscall dropping and therefore terminating the process is our best option.
child: JodGroupChild,
thread: stdx::thread::JoinHandle<io::Result<(bool, String)>>,
pub(crate) receiver: Receiver<T>,
program: OsString,
arguments: Vec<OsString>,
current_dir: Option<PathBuf>,
}
impl<T> fmt::Debug for CommandHandle<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandHandle")
.field("program", &self.program)
.field("arguments", &self.arguments)
.field("current_dir", &self.current_dir)
.finish()
}
}
impl<T: ParseFromLine> CommandHandle<T> {
pub(crate) fn spawn(mut command: Command) -> std::io::Result<Self> {
command.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::null());
let mut child = command.group_spawn().map(JodGroupChild)?;
let program = command.get_program().into();
let arguments = command.get_args().map(|arg| arg.into()).collect::<Vec<OsString>>();
let current_dir = command.get_current_dir().map(|arg| arg.to_path_buf());
let stdout = child.0.inner().stdout.take().unwrap();
let stderr = child.0.inner().stderr.take().unwrap();
let (sender, receiver) = unbounded();
let actor = CargoActor::<T>::new(sender, stdout, stderr);
let thread = stdx::thread::Builder::new(stdx::thread::ThreadIntent::Worker)
.name("CommandHandle".to_owned())
.spawn(move || actor.run())
.expect("failed to spawn thread");
Ok(CommandHandle { program, arguments, current_dir, child, thread, receiver })
}
pub(crate) fn cancel(mut self) {
let _ = self.child.0.kill();
let _ = self.child.0.wait();
}
pub(crate) fn join(mut self) -> io::Result<()> {
let _ = self.child.0.kill();
let exit_status = self.child.0.wait()?;
let (read_at_least_one_message, error) = self.thread.join()?;
if read_at_least_one_message || exit_status.success() {
Ok(())
} else {
Err(io::Error::new(io::ErrorKind::Other, format!(
"Cargo watcher failed, the command produced no valid metadata (exit code: {exit_status:?}):\n{error}"
)))
}
}
}

View file

@ -2,22 +2,18 @@
//! another compatible command (f.x. clippy) in a background thread and provide
//! LSP diagnostics based on the output of the command.
// FIXME: This crate now handles running `cargo test` needed in the test explorer in
// addition to `cargo check`. Either split it into 3 crates (one for test, one for check
// and one common utilities) or change its name and docs to reflect the current state.
#![warn(rust_2018_idioms, unused_lifetimes)]
use std::{
ffi::OsString,
fmt, io,
path::PathBuf,
process::{ChildStderr, ChildStdout, Command, Stdio},
time::Duration,
};
use std::{fmt, io, path::PathBuf, process::Command, time::Duration};
use command_group::{CommandGroup, GroupChild};
use crossbeam_channel::{never, select, unbounded, Receiver, Sender};
use paths::{AbsPath, AbsPathBuf};
use rustc_hash::FxHashMap;
use serde::Deserialize;
use stdx::process::streaming_output;
pub use cargo_metadata::diagnostic::{
Applicability, Diagnostic, DiagnosticCode, DiagnosticLevel, DiagnosticSpan,
@ -25,6 +21,12 @@ pub use cargo_metadata::diagnostic::{
};
use toolchain::Tool;
mod command;
mod test_runner;
use command::{CommandHandle, ParseFromLine};
pub use test_runner::{CargoTestHandle, CargoTestMessage, TestState};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum InvocationStrategy {
Once,
@ -181,12 +183,12 @@ struct FlycheckActor {
/// doesn't provide a way to read sub-process output without blocking, so we
/// have to wrap sub-processes output handling in a thread and pass messages
/// back over a channel.
command_handle: Option<CommandHandle>,
command_handle: Option<CommandHandle<CargoCheckMessage>>,
}
enum Event {
RequestStateChange(StateChange),
CheckEvent(Option<CargoMessage>),
CheckEvent(Option<CargoCheckMessage>),
}
const SAVED_FILE_PLACEHOLDER: &str = "$saved_file";
@ -282,7 +284,7 @@ impl FlycheckActor {
self.report_progress(Progress::DidFinish(res));
}
Event::CheckEvent(Some(message)) => match message {
CargoMessage::CompilerArtifact(msg) => {
CargoCheckMessage::CompilerArtifact(msg) => {
tracing::trace!(
flycheck_id = self.id,
artifact = msg.target.name,
@ -291,7 +293,7 @@ impl FlycheckActor {
self.report_progress(Progress::DidCheckCrate(msg.target.name));
}
CargoMessage::Diagnostic(msg) => {
CargoCheckMessage::Diagnostic(msg) => {
tracing::trace!(
flycheck_id = self.id,
message = msg.message,
@ -448,161 +450,42 @@ impl FlycheckActor {
}
}
struct JodGroupChild(GroupChild);
impl Drop for JodGroupChild {
fn drop(&mut self) {
_ = self.0.kill();
_ = self.0.wait();
}
}
/// A handle to a cargo process used for fly-checking.
struct CommandHandle {
/// The handle to the actual cargo process. As we cannot cancel directly from with
/// a read syscall dropping and therefore terminating the process is our best option.
child: JodGroupChild,
thread: stdx::thread::JoinHandle<io::Result<(bool, String)>>,
receiver: Receiver<CargoMessage>,
program: OsString,
arguments: Vec<OsString>,
current_dir: Option<PathBuf>,
}
impl fmt::Debug for CommandHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandHandle")
.field("program", &self.program)
.field("arguments", &self.arguments)
.field("current_dir", &self.current_dir)
.finish()
}
}
impl CommandHandle {
fn spawn(mut command: Command) -> std::io::Result<CommandHandle> {
command.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::null());
let mut child = command.group_spawn().map(JodGroupChild)?;
let program = command.get_program().into();
let arguments = command.get_args().map(|arg| arg.into()).collect::<Vec<OsString>>();
let current_dir = command.get_current_dir().map(|arg| arg.to_path_buf());
let stdout = child.0.inner().stdout.take().unwrap();
let stderr = child.0.inner().stderr.take().unwrap();
let (sender, receiver) = unbounded();
let actor = CargoActor::new(sender, stdout, stderr);
let thread = stdx::thread::Builder::new(stdx::thread::ThreadIntent::Worker)
.name("CommandHandle".to_owned())
.spawn(move || actor.run())
.expect("failed to spawn thread");
Ok(CommandHandle { program, arguments, current_dir, child, thread, receiver })
}
fn cancel(mut self) {
let _ = self.child.0.kill();
let _ = self.child.0.wait();
}
fn join(mut self) -> io::Result<()> {
let _ = self.child.0.kill();
let exit_status = self.child.0.wait()?;
let (read_at_least_one_message, error) = self.thread.join()?;
if read_at_least_one_message || exit_status.success() {
Ok(())
} else {
Err(io::Error::new(io::ErrorKind::Other, format!(
"Cargo watcher failed, the command produced no valid metadata (exit code: {exit_status:?}):\n{error}"
)))
}
}
}
struct CargoActor {
sender: Sender<CargoMessage>,
stdout: ChildStdout,
stderr: ChildStderr,
}
impl CargoActor {
fn new(sender: Sender<CargoMessage>, stdout: ChildStdout, stderr: ChildStderr) -> CargoActor {
CargoActor { sender, stdout, stderr }
}
fn run(self) -> io::Result<(bool, String)> {
// We manually read a line at a time, instead of using serde's
// stream deserializers, because the deserializer cannot recover
// from an error, resulting in it getting stuck, because we try to
// be resilient against failures.
//
// Because cargo only outputs one JSON object per line, we can
// simply skip a line if it doesn't parse, which just ignores any
// erroneous output.
let mut stdout_errors = String::new();
let mut stderr_errors = String::new();
let mut read_at_least_one_stdout_message = false;
let mut read_at_least_one_stderr_message = false;
let process_line = |line: &str, error: &mut String| {
// Try to deserialize a message from Cargo or Rustc.
let mut deserializer = serde_json::Deserializer::from_str(line);
deserializer.disable_recursion_limit();
if let Ok(message) = JsonMessage::deserialize(&mut deserializer) {
match message {
// Skip certain kinds of messages to only spend time on what's useful
JsonMessage::Cargo(message) => match message {
cargo_metadata::Message::CompilerArtifact(artifact) if !artifact.fresh => {
self.sender.send(CargoMessage::CompilerArtifact(artifact)).unwrap();
}
cargo_metadata::Message::CompilerMessage(msg) => {
self.sender.send(CargoMessage::Diagnostic(msg.message)).unwrap();
}
_ => (),
},
JsonMessage::Rustc(message) => {
self.sender.send(CargoMessage::Diagnostic(message)).unwrap();
}
}
return true;
}
error.push_str(line);
error.push('\n');
false
};
let output = streaming_output(
self.stdout,
self.stderr,
&mut |line| {
if process_line(line, &mut stdout_errors) {
read_at_least_one_stdout_message = true;
}
},
&mut |line| {
if process_line(line, &mut stderr_errors) {
read_at_least_one_stderr_message = true;
}
},
);
let read_at_least_one_message =
read_at_least_one_stdout_message || read_at_least_one_stderr_message;
let mut error = stdout_errors;
error.push_str(&stderr_errors);
match output {
Ok(_) => Ok((read_at_least_one_message, error)),
Err(e) => Err(io::Error::new(e.kind(), format!("{e:?}: {error}"))),
}
}
}
#[allow(clippy::large_enum_variant)]
enum CargoMessage {
enum CargoCheckMessage {
CompilerArtifact(cargo_metadata::Artifact),
Diagnostic(Diagnostic),
}
impl ParseFromLine for CargoCheckMessage {
fn from_line(line: &str, error: &mut String) -> Option<Self> {
let mut deserializer = serde_json::Deserializer::from_str(line);
deserializer.disable_recursion_limit();
if let Ok(message) = JsonMessage::deserialize(&mut deserializer) {
return match message {
// Skip certain kinds of messages to only spend time on what's useful
JsonMessage::Cargo(message) => match message {
cargo_metadata::Message::CompilerArtifact(artifact) if !artifact.fresh => {
Some(CargoCheckMessage::CompilerArtifact(artifact))
}
cargo_metadata::Message::CompilerMessage(msg) => {
Some(CargoCheckMessage::Diagnostic(msg.message))
}
_ => None,
},
JsonMessage::Rustc(message) => Some(CargoCheckMessage::Diagnostic(message)),
};
}
error.push_str(line);
error.push('\n');
None
}
fn from_eof() -> Option<Self> {
None
}
}
#[derive(Deserialize)]
#[serde(untagged)]
enum JsonMessage {

View file

@ -0,0 +1,75 @@
//! This module provides the functionality needed to run `cargo test` in a background
//! thread and report the result of each test in a channel.
use std::process::Command;
use crossbeam_channel::Receiver;
use serde::Deserialize;
use toolchain::Tool;
use crate::command::{CommandHandle, ParseFromLine};
#[derive(Debug, Deserialize)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum TestState {
Started,
Ok,
Failed { stdout: String },
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum CargoTestMessage {
Test {
name: String,
#[serde(flatten)]
state: TestState,
},
Suite,
Finished,
}
impl ParseFromLine for CargoTestMessage {
fn from_line(line: &str, error: &mut String) -> Option<Self> {
let mut deserializer = serde_json::Deserializer::from_str(line);
deserializer.disable_recursion_limit();
if let Ok(message) = CargoTestMessage::deserialize(&mut deserializer) {
return Some(message);
}
error.push_str(line);
error.push('\n');
None
}
fn from_eof() -> Option<Self> {
Some(CargoTestMessage::Finished)
}
}
#[derive(Debug)]
pub struct CargoTestHandle {
handle: CommandHandle<CargoTestMessage>,
}
// Example of a cargo test command:
// cargo test -- module::func -Z unstable-options --format=json
impl CargoTestHandle {
pub fn new(path: Option<&str>) -> std::io::Result<Self> {
let mut cmd = Command::new(Tool::Cargo.path());
cmd.env("RUSTC_BOOTSTRAP", "1");
cmd.arg("test");
cmd.arg("--");
if let Some(path) = path {
cmd.arg(path);
}
cmd.args(["-Z", "unstable-options"]);
cmd.arg("--format=json");
Ok(Self { handle: CommandHandle::spawn(cmd)? })
}
pub fn receiver(&self) -> &Receiver<CargoTestMessage> {
&self.handle.receiver
}
}

View file

@ -50,6 +50,7 @@ mod static_index;
mod status;
mod syntax_highlighting;
mod syntax_tree;
mod test_explorer;
mod typing;
mod view_crate_graph;
mod view_hir;
@ -108,6 +109,7 @@ pub use crate::{
tags::{Highlight, HlMod, HlMods, HlOperator, HlPunct, HlTag},
HighlightConfig, HlRange,
},
test_explorer::{TestItem, TestItemKind},
};
pub use hir::Semantics;
pub use ide_assists::{
@ -340,6 +342,18 @@ impl Analysis {
self.with_db(|db| view_item_tree::view_item_tree(db, file_id))
}
pub fn discover_test_roots(&self) -> Cancellable<Vec<TestItem>> {
self.with_db(test_explorer::discover_test_roots)
}
pub fn discover_tests_in_crate_by_test_id(&self, crate_id: &str) -> Cancellable<Vec<TestItem>> {
self.with_db(|db| test_explorer::discover_tests_in_crate_by_test_id(db, crate_id))
}
pub fn discover_tests_in_crate(&self, crate_id: CrateId) -> Cancellable<Vec<TestItem>> {
self.with_db(|db| test_explorer::discover_tests_in_crate(db, crate_id))
}
/// Renders the crate graph to GraphViz "dot" syntax.
pub fn view_crate_graph(&self, full: bool) -> Cancellable<Result<String, String>> {
self.with_db(|db| view_crate_graph::view_crate_graph(db, full))

View file

@ -0,0 +1,135 @@
//! Discovers tests
use hir::{Crate, Module, ModuleDef, Semantics};
use ide_db::{
base_db::{CrateGraph, CrateId, FileId, SourceDatabase},
RootDatabase,
};
use syntax::TextRange;
use crate::{navigation_target::ToNav, runnables::runnable_fn, Runnable, TryToNav};
#[derive(Debug)]
pub enum TestItemKind {
Crate,
Module,
Function,
}
#[derive(Debug)]
pub struct TestItem {
pub id: String,
pub kind: TestItemKind,
pub label: String,
pub parent: Option<String>,
pub file: Option<FileId>,
pub text_range: Option<TextRange>,
pub runnable: Option<Runnable>,
}
pub(crate) fn discover_test_roots(db: &RootDatabase) -> Vec<TestItem> {
let crate_graph = db.crate_graph();
crate_graph
.iter()
.filter(|&id| crate_graph[id].origin.is_local())
.filter_map(|id| Some(crate_graph[id].display_name.as_ref()?.to_string()))
.map(|id| TestItem {
kind: TestItemKind::Crate,
label: id.clone(),
id,
parent: None,
file: None,
text_range: None,
runnable: None,
})
.collect()
}
fn find_crate_by_id(crate_graph: &CrateGraph, crate_id: &str) -> Option<CrateId> {
// here, we use display_name as the crate id. This is not super ideal, but it works since we
// only show tests for the local crates.
crate_graph.iter().find(|&id| {
crate_graph[id].origin.is_local()
&& crate_graph[id].display_name.as_ref().is_some_and(|x| x.to_string() == crate_id)
})
}
fn discover_tests_in_module(db: &RootDatabase, module: Module, prefix_id: String) -> Vec<TestItem> {
let sema = Semantics::new(db);
let mut r = vec![];
for c in module.children(db) {
let module_name =
c.name(db).as_ref().and_then(|n| n.as_str()).unwrap_or("[mod without name]").to_owned();
let module_id = format!("{prefix_id}::{module_name}");
let module_children = discover_tests_in_module(db, c, module_id.clone());
if !module_children.is_empty() {
let nav = c.to_nav(db).call_site;
r.push(TestItem {
id: module_id,
kind: TestItemKind::Module,
label: module_name,
parent: Some(prefix_id.clone()),
file: Some(nav.file_id),
text_range: Some(nav.focus_or_full_range()),
runnable: None,
});
r.extend(module_children);
}
}
for def in module.declarations(db) {
let ModuleDef::Function(f) = def else {
continue;
};
if !f.is_test(db) {
continue;
}
let nav = f.try_to_nav(db).map(|r| r.call_site);
let fn_name = f.name(db).as_str().unwrap_or("[function without name]").to_owned();
r.push(TestItem {
id: format!("{prefix_id}::{fn_name}"),
kind: TestItemKind::Function,
label: fn_name,
parent: Some(prefix_id.clone()),
file: nav.as_ref().map(|n| n.file_id),
text_range: nav.as_ref().map(|n| n.focus_or_full_range()),
runnable: runnable_fn(&sema, f),
});
}
r
}
pub(crate) fn discover_tests_in_crate_by_test_id(
db: &RootDatabase,
crate_test_id: &str,
) -> Vec<TestItem> {
let crate_graph = db.crate_graph();
let Some(crate_id) = find_crate_by_id(&crate_graph, crate_test_id) else {
return vec![];
};
discover_tests_in_crate(db, crate_id)
}
pub(crate) fn discover_tests_in_crate(db: &RootDatabase, crate_id: CrateId) -> Vec<TestItem> {
let crate_graph = db.crate_graph();
if !crate_graph[crate_id].origin.is_local() {
return vec![];
}
let Some(crate_test_id) = &crate_graph[crate_id].display_name else {
return vec![];
};
let crate_test_id = crate_test_id.to_string();
let crate_id: Crate = crate_id.into();
let module = crate_id.root_module();
let mut r = vec![TestItem {
id: crate_test_id.clone(),
kind: TestItemKind::Crate,
label: crate_test_id.clone(),
parent: None,
file: None,
text_range: None,
runnable: None,
}];
r.extend(discover_tests_in_module(db, module, crate_test_id));
r
}

View file

@ -83,6 +83,9 @@ pub(crate) struct GlobalState {
pub(crate) flycheck_receiver: Receiver<flycheck::Message>,
pub(crate) last_flycheck_error: Option<String>,
// Test explorer
pub(crate) test_run_session: Option<flycheck::CargoTestHandle>,
// VFS
pub(crate) loader: Handle<Box<dyn vfs::loader::Handle>, Receiver<vfs::loader::Message>>,
pub(crate) vfs: Arc<RwLock<(vfs::Vfs, IntMap<FileId, LineEndings>)>>,
@ -212,6 +215,8 @@ impl GlobalState {
flycheck_receiver,
last_flycheck_error: None,
test_run_session: None,
vfs: Arc::new(RwLock::new((vfs::Vfs::default(), IntMap::default()))),
vfs_config_version: 0,
vfs_progress_config_version: 0,

View file

@ -0,0 +1,25 @@
//! Currently cargo does not emit crate name in the `cargo test --format=json`, which needs to be changed. This
//! module contains a way to recover crate names in a very hacky and wrong way.
// FIXME(hack_recover_crate_name): Remove this module.
use std::sync::{Mutex, MutexGuard, OnceLock};
use ide_db::FxHashMap;
static STORAGE: OnceLock<Mutex<FxHashMap<String, String>>> = OnceLock::new();
fn get_storage() -> MutexGuard<'static, FxHashMap<String, String>> {
STORAGE.get_or_init(|| Mutex::new(FxHashMap::default())).lock().unwrap()
}
pub(crate) fn insert_name(name_with_crate: String) {
let Some((_, name_without_crate)) = name_with_crate.split_once("::") else {
return;
};
get_storage().insert(name_without_crate.to_owned(), name_with_crate);
}
pub(crate) fn lookup_name(name_without_crate: String) -> Option<String> {
get_storage().get(&name_without_crate).cloned()
}

View file

@ -16,7 +16,7 @@ use crate::{
config::Config,
global_state::GlobalState,
lsp::{from_proto, utils::apply_document_changes},
lsp_ext::RunFlycheckParams,
lsp_ext::{self, RunFlycheckParams},
mem_docs::DocumentData,
reload,
};
@ -373,3 +373,10 @@ pub(crate) fn handle_run_flycheck(
}
Ok(())
}
pub(crate) fn handle_abort_run_test(state: &mut GlobalState, _: ()) -> anyhow::Result<()> {
if state.test_run_session.take().is_some() {
state.send_notification::<lsp_ext::EndRunTest>(());
}
Ok(())
}

View file

@ -39,6 +39,7 @@ use crate::{
config::{Config, RustfmtConfig, WorkspaceSymbolConfig},
diff::diff,
global_state::{GlobalState, GlobalStateSnapshot},
hack_recover_crate_name,
line_index::LineEndings,
lsp::{
from_proto, to_proto,
@ -192,6 +193,70 @@ pub(crate) fn handle_view_item_tree(
Ok(res)
}
pub(crate) fn handle_run_test(
state: &mut GlobalState,
params: lsp_ext::RunTestParams,
) -> anyhow::Result<()> {
if let Some(_session) = state.test_run_session.take() {
state.send_notification::<lsp_ext::EndRunTest>(());
}
// We detect the lowest common ansector of all included tests, and
// run it. We ignore excluded tests for now, the client will handle
// it for us.
let lca = match params.include {
Some(tests) => tests
.into_iter()
.reduce(|x, y| {
let mut common_prefix = "".to_owned();
for (xc, yc) in x.chars().zip(y.chars()) {
if xc != yc {
break;
}
common_prefix.push(xc);
}
common_prefix
})
.unwrap_or_default(),
None => "".to_owned(),
};
let handle = if lca.is_empty() {
flycheck::CargoTestHandle::new(None)
} else if let Some((_, path)) = lca.split_once("::") {
flycheck::CargoTestHandle::new(Some(path))
} else {
flycheck::CargoTestHandle::new(None)
};
state.test_run_session = Some(handle?);
Ok(())
}
pub(crate) fn handle_discover_test(
snap: GlobalStateSnapshot,
params: lsp_ext::DiscoverTestParams,
) -> anyhow::Result<lsp_ext::DiscoverTestResults> {
let _p = tracing::span!(tracing::Level::INFO, "handle_discover_test").entered();
let (tests, scope) = match params.test_id {
Some(id) => {
let crate_id = id.split_once("::").map(|it| it.0).unwrap_or(&id);
(snap.analysis.discover_tests_in_crate_by_test_id(crate_id)?, vec![crate_id.to_owned()])
}
None => (snap.analysis.discover_test_roots()?, vec![]),
};
for t in &tests {
hack_recover_crate_name::insert_name(t.id.clone());
}
Ok(lsp_ext::DiscoverTestResults {
tests: tests
.into_iter()
.map(|t| {
let line_index = t.file.and_then(|f| snap.file_line_index(f).ok());
to_proto::test_item(&snap, t, line_index.as_ref())
})
.collect(),
scope,
})
}
pub(crate) fn handle_view_crate_graph(
snap: GlobalStateSnapshot,
params: ViewCrateGraphParams,

View file

@ -19,6 +19,7 @@ mod diagnostics;
mod diff;
mod dispatch;
mod global_state;
mod hack_recover_crate_name;
mod line_index;
mod main_loop;
mod mem_docs;

View file

@ -163,6 +163,108 @@ impl Request for ViewItemTree {
const METHOD: &'static str = "rust-analyzer/viewItemTree";
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DiscoverTestParams {
pub test_id: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub enum TestItemIcon {
Package,
Module,
Test,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct TestItem {
pub id: String,
pub label: String,
pub icon: TestItemIcon,
pub can_resolve_children: bool,
pub parent: Option<String>,
pub text_document: Option<TextDocumentIdentifier>,
pub range: Option<Range>,
pub runnable: Option<Runnable>,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct DiscoverTestResults {
pub tests: Vec<TestItem>,
pub scope: Vec<String>,
}
pub enum DiscoverTest {}
impl Request for DiscoverTest {
type Params = DiscoverTestParams;
type Result = DiscoverTestResults;
const METHOD: &'static str = "experimental/discoverTest";
}
pub enum DiscoveredTests {}
impl Notification for DiscoveredTests {
type Params = DiscoverTestResults;
const METHOD: &'static str = "experimental/discoveredTests";
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RunTestParams {
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
}
pub enum RunTest {}
impl Request for RunTest {
type Params = RunTestParams;
type Result = ();
const METHOD: &'static str = "experimental/runTest";
}
pub enum EndRunTest {}
impl Notification for EndRunTest {
type Params = ();
const METHOD: &'static str = "experimental/endRunTest";
}
pub enum AbortRunTest {}
impl Notification for AbortRunTest {
type Params = ();
const METHOD: &'static str = "experimental/abortRunTest";
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase", tag = "tag")]
pub enum TestState {
Passed,
Failed { message: String },
Skipped,
Started,
Enqueued,
}
#[derive(Deserialize, Serialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct ChangeTestStateParams {
pub test_id: String,
pub state: TestState,
}
pub enum ChangeTestState {}
impl Notification for ChangeTestState {
type Params = ChangeTestStateParams;
const METHOD: &'static str = "experimental/changeTestState";
}
pub enum ExpandMacro {}
impl Request for ExpandMacro {

View file

@ -1498,6 +1498,32 @@ pub(crate) fn code_lens(
Ok(())
}
pub(crate) fn test_item(
snap: &GlobalStateSnapshot,
test_item: ide::TestItem,
line_index: Option<&LineIndex>,
) -> lsp_ext::TestItem {
lsp_ext::TestItem {
id: test_item.id,
label: test_item.label,
icon: match test_item.kind {
ide::TestItemKind::Crate => lsp_ext::TestItemIcon::Package,
ide::TestItemKind::Module => lsp_ext::TestItemIcon::Module,
ide::TestItemKind::Function => lsp_ext::TestItemIcon::Test,
},
can_resolve_children: matches!(
test_item.kind,
ide::TestItemKind::Crate | ide::TestItemKind::Module
),
parent: test_item.parent,
text_document: test_item
.file
.map(|f| lsp_types::TextDocumentIdentifier { uri: url(snap, f) }),
range: line_index.and_then(|l| Some(range(l, test_item.text_range?))),
runnable: test_item.runnable.and_then(|r| runnable(snap, r).ok()),
}
}
pub(crate) mod command {
use ide::{FileRange, NavigationTarget};
use serde_json::to_value;

View file

@ -1,14 +1,15 @@
//! The main loop of `rust-analyzer` responsible for dispatching LSP
//! requests/replies and notifications back to the client.
use crate::lsp::ext;
use std::{
fmt,
time::{Duration, Instant},
};
use always_assert::always;
use crossbeam_channel::{select, Receiver};
use crossbeam_channel::{never, select, Receiver};
use ide_db::base_db::{SourceDatabase, SourceDatabaseExt, VfsPath};
use itertools::Itertools;
use lsp_server::{Connection, Notification, Request};
use lsp_types::notification::Notification as _;
use stdx::thread::ThreadIntent;
@ -19,8 +20,9 @@ use crate::{
diagnostics::fetch_native_diagnostics,
dispatch::{NotificationDispatcher, RequestDispatcher},
global_state::{file_id_to_url, url_to_file_id, GlobalState},
hack_recover_crate_name,
lsp::{
from_proto,
from_proto, to_proto,
utils::{notification_is, Progress},
},
lsp_ext,
@ -58,6 +60,7 @@ enum Event {
QueuedTask(QueuedTask),
Vfs(vfs::loader::Message),
Flycheck(flycheck::Message),
TestResult(flycheck::CargoTestMessage),
}
impl fmt::Display for Event {
@ -68,6 +71,7 @@ impl fmt::Display for Event {
Event::Vfs(_) => write!(f, "Event::Vfs"),
Event::Flycheck(_) => write!(f, "Event::Flycheck"),
Event::QueuedTask(_) => write!(f, "Event::QueuedTask"),
Event::TestResult(_) => write!(f, "Event::TestResult"),
}
}
}
@ -81,9 +85,10 @@ pub(crate) enum QueuedTask {
#[derive(Debug)]
pub(crate) enum Task {
Response(lsp_server::Response),
ClientNotification(ext::UnindexedProjectParams),
ClientNotification(lsp_ext::UnindexedProjectParams),
Retry(lsp_server::Request),
Diagnostics(Vec<(FileId, Vec<lsp_types::Diagnostic>)>),
DiscoverTest(lsp_ext::DiscoverTestResults),
PrimeCaches(PrimeCachesProgress),
FetchWorkspace(ProjectWorkspaceProgress),
FetchBuildData(BuildDataProgress),
@ -127,6 +132,7 @@ impl fmt::Debug for Event {
Event::QueuedTask(it) => fmt::Debug::fmt(it, f),
Event::Vfs(it) => fmt::Debug::fmt(it, f),
Event::Flycheck(it) => fmt::Debug::fmt(it, f),
Event::TestResult(it) => fmt::Debug::fmt(it, f),
}
}
}
@ -214,6 +220,10 @@ impl GlobalState {
recv(self.flycheck_receiver) -> task =>
Some(Event::Flycheck(task.unwrap())),
recv(self.test_run_session.as_ref().map(|s| s.receiver()).unwrap_or(&never())) -> task =>
Some(Event::TestResult(task.unwrap())),
}
}
@ -322,6 +332,18 @@ impl GlobalState {
self.handle_flycheck_msg(message);
}
}
Event::TestResult(message) => {
let _p =
tracing::span!(tracing::Level::INFO, "GlobalState::handle_event/test_result")
.entered();
self.handle_cargo_test_msg(message);
// Coalesce many test result event into a single loop turn
while let Some(message) =
self.test_run_session.as_ref().and_then(|r| r.receiver().try_recv().ok())
{
self.handle_cargo_test_msg(message);
}
}
}
let event_handling_duration = loop_start.elapsed();
@ -367,7 +389,8 @@ impl GlobalState {
let update_diagnostics = (!was_quiescent || state_changed || memdocs_added_or_removed)
&& self.config.publish_diagnostics();
if update_diagnostics {
self.update_diagnostics()
self.update_diagnostics();
self.update_tests();
}
}
@ -488,6 +511,55 @@ impl GlobalState {
});
}
fn update_tests(&mut self) {
let db = self.analysis_host.raw_database();
let subscriptions = self
.mem_docs
.iter()
.map(|path| self.vfs.read().0.file_id(path).unwrap())
.filter(|&file_id| {
let source_root = db.file_source_root(file_id);
!db.source_root(source_root).is_library
})
.collect::<Vec<_>>();
tracing::trace!("updating tests for {:?}", subscriptions);
// Updating tests are triggered by the user typing
// so we run them on a latency sensitive thread.
self.task_pool.handle.spawn(ThreadIntent::LatencySensitive, {
let snapshot = self.snapshot();
move || {
let tests = subscriptions
.into_iter()
.filter_map(|f| snapshot.analysis.crates_for(f).ok())
.flatten()
.unique()
.filter_map(|c| snapshot.analysis.discover_tests_in_crate(c).ok())
.flatten()
.collect::<Vec<_>>();
for t in &tests {
hack_recover_crate_name::insert_name(t.id.clone());
}
let scope = tests
.iter()
.filter_map(|t| Some(t.id.split_once("::")?.0))
.unique()
.map(|it| it.to_owned())
.collect();
Task::DiscoverTest(lsp_ext::DiscoverTestResults {
tests: tests
.into_iter()
.map(|t| {
let line_index = t.file.and_then(|f| snapshot.file_line_index(f).ok());
to_proto::test_item(&snapshot, t, line_index.as_ref())
})
.collect(),
scope,
})
}
});
}
fn update_status_or_notify(&mut self) {
let status = self.current_status();
if self.last_reported_status.as_ref() != Some(&status) {
@ -598,6 +670,9 @@ impl GlobalState {
}
}
Task::BuildDepsHaveChanged => self.build_deps_changed = true,
Task::DiscoverTest(tests) => {
self.send_notification::<lsp_ext::DiscoveredTests>(tests);
}
}
}
@ -666,7 +741,7 @@ impl GlobalState {
let id = from_proto::file_id(&snap, &uri).expect("unable to get FileId");
if let Ok(crates) = &snap.analysis.crates_for(id) {
if crates.is_empty() {
let params = ext::UnindexedProjectParams {
let params = lsp_ext::UnindexedProjectParams {
text_documents: vec![lsp_types::TextDocumentIdentifier { uri }],
};
sender.send(Task::ClientNotification(params)).unwrap();
@ -698,6 +773,31 @@ impl GlobalState {
}
}
fn handle_cargo_test_msg(&mut self, message: flycheck::CargoTestMessage) {
match message {
flycheck::CargoTestMessage::Test { name, state } => {
let state = match state {
flycheck::TestState::Started => lsp_ext::TestState::Started,
flycheck::TestState::Ok => lsp_ext::TestState::Passed,
flycheck::TestState::Failed { stdout } => {
lsp_ext::TestState::Failed { message: stdout }
}
};
let Some(test_id) = hack_recover_crate_name::lookup_name(name) else {
return;
};
self.send_notification::<lsp_ext::ChangeTestState>(
lsp_ext::ChangeTestStateParams { test_id, state },
);
}
flycheck::CargoTestMessage::Suite => (),
flycheck::CargoTestMessage::Finished => {
self.send_notification::<lsp_ext::EndRunTest>(());
self.test_run_session = None;
}
}
}
fn handle_flycheck_msg(&mut self, message: flycheck::Message) {
match message {
flycheck::Message::AddDiagnostic { id, workspace_root, diagnostic } => {
@ -803,6 +903,7 @@ impl GlobalState {
.on_sync_mut::<lsp_ext::RebuildProcMacros>(handlers::handle_proc_macros_rebuild)
.on_sync_mut::<lsp_ext::MemoryUsage>(handlers::handle_memory_usage)
.on_sync_mut::<lsp_ext::ShuffleCrateGraph>(handlers::handle_shuffle_crate_graph)
.on_sync_mut::<lsp_ext::RunTest>(handlers::handle_run_test)
// Request handlers which are related to the user typing
// are run on the main thread to reduce latency:
.on_sync::<lsp_ext::JoinLines>(handlers::handle_join_lines)
@ -843,6 +944,7 @@ impl GlobalState {
.on::<lsp_ext::ViewFileText>(handlers::handle_view_file_text)
.on::<lsp_ext::ViewCrateGraph>(handlers::handle_view_crate_graph)
.on::<lsp_ext::ViewItemTree>(handlers::handle_view_item_tree)
.on::<lsp_ext::DiscoverTest>(handlers::handle_discover_test)
.on::<lsp_ext::ExpandMacro>(handlers::handle_expand_macro)
.on::<lsp_ext::ParentModule>(handlers::handle_parent_module)
.on::<lsp_ext::Runnables>(handlers::handle_runnables)
@ -906,6 +1008,7 @@ impl GlobalState {
.on_sync_mut::<lsp_ext::CancelFlycheck>(handlers::handle_cancel_flycheck)?
.on_sync_mut::<lsp_ext::ClearFlycheck>(handlers::handle_clear_flycheck)?
.on_sync_mut::<lsp_ext::RunFlycheck>(handlers::handle_run_flycheck)?
.on_sync_mut::<lsp_ext::AbortRunTest>(handlers::handle_abort_run_test)?
.finish();
Ok(())
}

View file

@ -15,6 +15,7 @@ pub fn streaming_output(
err: ChildStderr,
on_stdout_line: &mut dyn FnMut(&str),
on_stderr_line: &mut dyn FnMut(&str),
on_eof: &mut dyn FnMut(),
) -> io::Result<(Vec<u8>, Vec<u8>)> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
@ -44,6 +45,9 @@ pub fn streaming_output(
on_stderr_line(line);
}
}
if eof {
on_eof();
}
}
})?;
@ -63,6 +67,7 @@ pub fn spawn_with_streaming_output(
child.stderr.take().unwrap(),
on_stdout_line,
on_stderr_line,
&mut || (),
)?;
let status = child.wait()?;
Ok(Output { status, stdout, stderr })

View file

@ -1,5 +1,5 @@
<!---
lsp/ext.rs hash: 8be79cc3b7f10ad7
lsp/ext.rs hash: 4b06686d086b7d9b
If you need to change the above hash to make the test pass, please check if you
need to adjust this doc as well and ping this issue:
@ -385,6 +385,106 @@ rust-analyzer supports only one `kind`, `"cargo"`. The `args` for `"cargo"` look
}
```
## Test explorer
**Method:** `experimental/discoverTest`
**Request:** `DiscoverTestParams`
```typescript
interface DiscoverTestParams {
// The test that we need to resolve its children. If not present,
// the response should return top level tests.
testId?: string | undefined;
}
```
**Response:** `DiscoverTestResults`
```typescript
interface TestItem {
// A unique identifier for the test
id: string;
// The file containing this test
textDocument?: lc.TextDocumentIdentifier | undefined;
// The range in the file containing this test
range?: lc.Range | undefined;
// A human readable name for this test
label: string;
icon: "package" | "module" | "test";
// True if this test may have children not available eagerly
canResolveChildren: boolean;
// The id of the parent test in the test tree. If not present, this test
// is a top level test.
parent?: string | undefined;
// The information useful for running the test. The client can use `runTest`
// request for simple execution, but for more complex execution forms
// like debugging, this field is useful.
runnable?: Runnable | undefined;
};
interface DiscoverTestResults {
// The discovered tests.
tests: TestItem[];
// For each test which its id is in this list, the response
// contains all tests that are children of this test, and
// client should remove old tests not included in the response.
scope: string[];
}
```
**Method:** `experimental/discoveredTests`
**Notification:** `DiscoverTestResults`
This notification is sent from the server to the client when the
server detect changes in the existing tests. The `DiscoverTestResults` is
the same as the one in `experimental/discoverTest` response.
**Method:** `experimental/runTest`
**Request:** `RunTestParams`
```typescript
interface DiscoverTestParams {
include?: string[] | undefined;
exclude?: string[] | undefined;
}
```
**Response:** `void`
**Method:** `experimental/endRunTest`
**Notification:**
This notification is sent from the server to the client when the current running
session is finished. The server should not send any run notification
after this.
**Method:** `experimental/abortRunTest`
**Notification:**
This notification is sent from the client to the server when the user is no longer
interested in the test results. The server should clean up its resources and send
a `experimental/endRunTest` when is done.
**Method:** `experimental/changeTestState`
**Notification:** `ChangeTestStateParams`
```typescript
type TestState = { tag: "failed"; message: string }
| { tag: "passed" }
| { tag: "started" };
interface ChangeTestStateParams {
testId: string;
state: TestState;
}
```
## Open External Documentation
This request is sent from the client to the server to obtain web and local URL(s) for documentation related to the symbol under the cursor, if available.

View file

@ -24,6 +24,7 @@ import { PersistentState } from "./persistent_state";
import { bootstrap } from "./bootstrap";
import type { RustAnalyzerExtensionApi } from "./main";
import type { JsonProject } from "./rust_project";
import { prepareTestExplorer } from "./test_explorer";
// We only support local folders, not eg. Live Share (`vlsl:` scheme), so don't activate if
// only those are in use. We use "Empty" to represent these scenarios
@ -74,6 +75,7 @@ export class Ctx implements RustAnalyzerExtensionApi {
private _client: lc.LanguageClient | undefined;
private _serverPath: string | undefined;
private traceOutputChannel: vscode.OutputChannel | undefined;
private testController: vscode.TestController;
private outputChannel: vscode.OutputChannel | undefined;
private clientSubscriptions: Disposable[];
private state: PersistentState;
@ -103,6 +105,10 @@ export class Ctx implements RustAnalyzerExtensionApi {
) {
extCtx.subscriptions.push(this);
this.statusBar = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left);
this.testController = vscode.tests.createTestController(
"rustAnalyzerTestController",
"Rust Analyzer test controller",
);
this.workspace = workspace;
this.clientSubscriptions = [];
this.commandDisposables = [];
@ -120,6 +126,7 @@ export class Ctx implements RustAnalyzerExtensionApi {
dispose() {
this.config.dispose();
this.statusBar.dispose();
this.testController.dispose();
void this.disposeClient();
this.commandDisposables.forEach((disposable) => disposable.dispose());
}
@ -264,6 +271,7 @@ export class Ctx implements RustAnalyzerExtensionApi {
await client.start();
this.updateCommands();
prepareTestExplorer(this, this.testController, client);
if (this.config.showDependenciesExplorer) {
this.prepareTreeDependenciesView(client);
}
@ -491,7 +499,7 @@ export class Ctx implements RustAnalyzerExtensionApi {
this.extCtx.subscriptions.push(d);
}
private pushClientCleanup(d: Disposable) {
pushClientCleanup(d: Disposable) {
this.clientSubscriptions.push(d);
}
}

View file

@ -68,6 +68,37 @@ export const viewItemTree = new lc.RequestType<ViewItemTreeParams, string, void>
"rust-analyzer/viewItemTree",
);
export type DiscoverTestParams = { testId?: string | undefined };
export type RunTestParams = {
include?: string[] | undefined;
exclude?: string[] | undefined;
};
export type TestItem = {
id: string;
label: string;
icon: "package" | "module" | "test";
canResolveChildren: boolean;
parent?: string | undefined;
textDocument?: lc.TextDocumentIdentifier | undefined;
range?: lc.Range | undefined;
runnable?: Runnable | undefined;
};
export type DiscoverTestResults = { tests: TestItem[]; scope: string[] };
export type TestState = { tag: "failed"; message: string } | { tag: "passed" } | { tag: "started" };
export type ChangeTestStateParams = { testId: string; state: TestState };
export const discoverTest = new lc.RequestType<DiscoverTestParams, DiscoverTestResults, void>(
"experimental/discoverTest",
);
export const discoveredTests = new lc.NotificationType<DiscoverTestResults>(
"experimental/discoveredTests",
);
export const runTest = new lc.RequestType<RunTestParams, void, void>("experimental/runTest");
export const abortRunTest = new lc.NotificationType0("experimental/abortRunTest");
export const endRunTest = new lc.NotificationType0("experimental/endRunTest");
export const changeTestState = new lc.NotificationType<ChangeTestStateParams>(
"experimental/changeTestState",
);
export type AnalyzerStatusParams = { textDocument?: lc.TextDocumentIdentifier };
export interface FetchDependencyListParams {}

View file

@ -0,0 +1,169 @@
import * as vscode from "vscode";
import type * as lc from "vscode-languageclient/node";
import * as ra from "./lsp_ext";
import type { Ctx } from "./ctx";
import { startDebugSession } from "./debug";
export const prepareTestExplorer = (
ctx: Ctx,
testController: vscode.TestController,
client: lc.LanguageClient,
) => {
let currentTestRun: vscode.TestRun | undefined;
let idToTestMap: Map<string, vscode.TestItem> = new Map();
const idToRunnableMap: Map<string, ra.Runnable> = new Map();
testController.createRunProfile(
"Run Tests",
vscode.TestRunProfileKind.Run,
async (request: vscode.TestRunRequest, cancelToken: vscode.CancellationToken) => {
if (currentTestRun) {
await client.sendNotification(ra.abortRunTest);
while (currentTestRun) {
await new Promise((resolve) => setTimeout(resolve, 1));
}
}
currentTestRun = testController.createTestRun(request);
cancelToken.onCancellationRequested(async () => {
await client.sendNotification(ra.abortRunTest);
});
const include = request.include?.map((x) => x.id);
const exclude = request.exclude?.map((x) => x.id);
await client.sendRequest(ra.runTest, { include, exclude });
},
true,
undefined,
false,
);
testController.createRunProfile(
"Debug Tests",
vscode.TestRunProfileKind.Debug,
async (request: vscode.TestRunRequest) => {
if (request.include?.length !== 1 || request.exclude?.length !== 0) {
await vscode.window.showErrorMessage("You can debug only one test at a time");
return;
}
const id = request.include[0]!.id;
const runnable = idToRunnableMap.get(id);
if (!runnable) {
await vscode.window.showErrorMessage("You can debug only one test at a time");
return;
}
await startDebugSession(ctx, runnable);
},
true,
undefined,
false,
);
const addTest = (item: ra.TestItem) => {
const parentList = item.parent
? idToTestMap.get(item.parent)!.children
: testController.items;
const oldTest = parentList.get(item.id);
const uri = item.textDocument?.uri ? vscode.Uri.parse(item.textDocument?.uri) : undefined;
const range =
item.range &&
new vscode.Range(
new vscode.Position(item.range.start.line, item.range.start.character),
new vscode.Position(item.range.end.line, item.range.end.character),
);
if (oldTest) {
if (oldTest.uri?.toString() === uri?.toString()) {
oldTest.range = range;
return;
}
parentList.delete(item.id);
}
const iconToVscodeMap = {
package: "package",
module: "symbol-module",
test: "beaker",
};
const test = testController.createTestItem(
item.id,
`$(${iconToVscodeMap[item.icon]}) ${item.label}`,
uri,
);
test.range = range;
test.canResolveChildren = item.canResolveChildren;
idToTestMap.set(item.id, test);
if (item.runnable) {
idToRunnableMap.set(item.id, item.runnable);
}
parentList.add(test);
};
const addTestGroup = (testsAndScope: ra.DiscoverTestResults) => {
const { tests, scope } = testsAndScope;
const testSet: Set<string> = new Set();
for (const test of tests) {
addTest(test);
testSet.add(test.id);
}
// FIXME(hack_recover_crate_name): We eagerly resolve every test if we got a lazy top level response (detected
// by `!scope`). ctx is not a good thing and wastes cpu and memory unnecessarily, so we should remove it.
if (!scope) {
for (const test of tests) {
void testController.resolveHandler!(idToTestMap.get(test.id));
}
}
if (!scope) {
return;
}
const recursivelyRemove = (tests: vscode.TestItemCollection) => {
for (const [testId, _] of tests) {
if (!testSet.has(testId)) {
tests.delete(testId);
} else {
recursivelyRemove(tests.get(testId)!.children);
}
}
};
for (const root of scope) {
recursivelyRemove(idToTestMap.get(root)!.children);
}
};
ctx.pushClientCleanup(
client.onNotification(ra.discoveredTests, (results) => {
addTestGroup(results);
}),
);
ctx.pushClientCleanup(
client.onNotification(ra.endRunTest, () => {
currentTestRun!.end();
currentTestRun = undefined;
}),
);
ctx.pushClientCleanup(
client.onNotification(ra.changeTestState, (results) => {
const test = idToTestMap.get(results.testId)!;
if (results.state.tag === "failed") {
currentTestRun!.failed(test, new vscode.TestMessage(results.state.message));
} else if (results.state.tag === "passed") {
currentTestRun!.passed(test);
} else if (results.state.tag === "started") {
currentTestRun!.started(test);
}
}),
);
testController.resolveHandler = async (item) => {
const results = await client.sendRequest(ra.discoverTest, { testId: item?.id });
addTestGroup(results);
};
testController.refreshHandler = async () => {
testController.items.forEach((t) => {
testController.items.delete(t.id);
});
idToTestMap = new Map();
await testController.resolveHandler!(undefined);
};
};