Unnamed repository; edit this file 'description' to name the repository.
Add test explorer
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/flycheck/src/command.rs | 156 | ||||
| -rw-r--r-- | crates/flycheck/src/lib.rs | 201 | ||||
| -rw-r--r-- | crates/flycheck/src/test_runner.rs | 75 | ||||
| -rw-r--r-- | crates/ide/src/lib.rs | 14 | ||||
| -rw-r--r-- | crates/ide/src/test_explorer.rs | 135 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/global_state.rs | 5 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/hack_recover_crate_name.rs | 25 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/handlers/notification.rs | 9 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/handlers/request.rs | 65 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/lsp/ext.rs | 102 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/lsp/to_proto.rs | 26 | ||||
| -rw-r--r-- | crates/rust-analyzer/src/main_loop.rs | 115 | ||||
| -rw-r--r-- | crates/stdx/src/process.rs | 5 | ||||
| -rw-r--r-- | docs/dev/lsp-extensions.md | 102 | ||||
| -rw-r--r-- | editors/code/src/ctx.ts | 10 | ||||
| -rw-r--r-- | editors/code/src/lsp_ext.ts | 31 | ||||
| -rw-r--r-- | editors/code/src/test_explorer.ts | 169 |
19 files changed, 1079 insertions, 168 deletions
diff --git a/Cargo.toml b/Cargo.toml index e8e82914c7..eb3be72396 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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] diff --git a/crates/flycheck/src/command.rs b/crates/flycheck/src/command.rs new file mode 100644 index 0000000000..091146a001 --- /dev/null +++ b/crates/flycheck/src/command.rs @@ -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}" + ))) + } + } +} diff --git a/crates/flycheck/src/lib.rs b/crates/flycheck/src/lib.rs index 8bcdca5bb8..f8efb52022 100644 --- a/crates/flycheck/src/lib.rs +++ b/crates/flycheck/src/lib.rs @@ -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() - } +#[allow(clippy::large_enum_variant)] +enum CargoCheckMessage { + CompilerArtifact(cargo_metadata::Artifact), + Diagnostic(Diagnostic), } -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}" - ))) +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)), + }; } - } -} -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 } + error.push_str(line); + error.push('\n'); + None } - 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}"))), - } + fn from_eof() -> Option<Self> { + None } } -#[allow(clippy::large_enum_variant)] -enum CargoMessage { - CompilerArtifact(cargo_metadata::Artifact), - Diagnostic(Diagnostic), -} - #[derive(Deserialize)] #[serde(untagged)] enum JsonMessage { diff --git a/crates/flycheck/src/test_runner.rs b/crates/flycheck/src/test_runner.rs new file mode 100644 index 0000000000..b7d966989b --- /dev/null +++ b/crates/flycheck/src/test_runner.rs @@ -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 + } +} diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 4eb4acdf18..59a7df14fd 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -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)) diff --git a/crates/ide/src/test_explorer.rs b/crates/ide/src/test_explorer.rs new file mode 100644 index 0000000000..2e741021ea --- /dev/null +++ b/crates/ide/src/test_explorer.rs @@ -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 +} diff --git a/crates/rust-analyzer/src/global_state.rs b/crates/rust-analyzer/src/global_state.rs index 8b8d0ebcb9..560410e332 100644 --- a/crates/rust-analyzer/src/global_state.rs +++ b/crates/rust-analyzer/src/global_state.rs @@ -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, diff --git a/crates/rust-analyzer/src/hack_recover_crate_name.rs b/crates/rust-analyzer/src/hack_recover_crate_name.rs new file mode 100644 index 0000000000..d7285653c5 --- /dev/null +++ b/crates/rust-analyzer/src/hack_recover_crate_name.rs @@ -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() +} diff --git a/crates/rust-analyzer/src/handlers/notification.rs b/crates/rust-analyzer/src/handlers/notification.rs index cf646a2e28..ff213748b4 100644 --- a/crates/rust-analyzer/src/handlers/notification.rs +++ b/crates/rust-analyzer/src/handlers/notification.rs @@ -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(()) +} diff --git a/crates/rust-analyzer/src/handlers/request.rs b/crates/rust-analyzer/src/handlers/request.rs index 0e005975fb..cc4333bd1d 100644 --- a/crates/rust-analyzer/src/handlers/request.rs +++ b/crates/rust-analyzer/src/handlers/request.rs @@ -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, diff --git a/crates/rust-analyzer/src/lib.rs b/crates/rust-analyzer/src/lib.rs index 473ca991ad..175ffa622f 100644 --- a/crates/rust-analyzer/src/lib.rs +++ b/crates/rust-analyzer/src/lib.rs @@ -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; diff --git a/crates/rust-analyzer/src/lsp/ext.rs b/crates/rust-analyzer/src/lsp/ext.rs index aa40728ce6..842e765d9d 100644 --- a/crates/rust-analyzer/src/lsp/ext.rs +++ b/crates/rust-analyzer/src/lsp/ext.rs @@ -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 { diff --git a/crates/rust-analyzer/src/lsp/to_proto.rs b/crates/rust-analyzer/src/lsp/to_proto.rs index 481ebfefd4..f0bb7d8af3 100644 --- a/crates/rust-analyzer/src/lsp/to_proto.rs +++ b/crates/rust-analyzer/src/lsp/to_proto.rs @@ -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; diff --git a/crates/rust-analyzer/src/main_loop.rs b/crates/rust-analyzer/src/main_loop.rs index 72f6d0fde5..87da95c87e 100644 --- a/crates/rust-analyzer/src/main_loop.rs +++ b/crates/rust-analyzer/src/main_loop.rs @@ -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(()) } diff --git a/crates/stdx/src/process.rs b/crates/stdx/src/process.rs index bca0cbc36d..e6935f06b2 100644 --- a/crates/stdx/src/process.rs +++ b/crates/stdx/src/process.rs @@ -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 }) diff --git a/docs/dev/lsp-extensions.md b/docs/dev/lsp-extensions.md index f3100ee194..8d523d7baa 100644 --- a/docs/dev/lsp-extensions.md +++ b/docs/dev/lsp-extensions.md @@ -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. diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts index 01a3aca132..9be846f969 100644 --- a/editors/code/src/ctx.ts +++ b/editors/code/src/ctx.ts @@ -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); } } diff --git a/editors/code/src/lsp_ext.ts b/editors/code/src/lsp_ext.ts index 6c961f53e7..acbc5cbeae 100644 --- a/editors/code/src/lsp_ext.ts +++ b/editors/code/src/lsp_ext.ts @@ -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 {} diff --git a/editors/code/src/test_explorer.ts b/editors/code/src/test_explorer.ts new file mode 100644 index 0000000000..e89a5cfc39 --- /dev/null +++ b/editors/code/src/test_explorer.ts @@ -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); + }; +}; |