From 44be2432f50be8d7476f65b18220027110d7bd99 Mon Sep 17 00:00:00 2001 From: hkalbasi Date: Fri, 1 Mar 2024 13:40:29 +0330 Subject: [PATCH] Add test explorer --- Cargo.toml | 1 + crates/flycheck/src/command.rs | 156 ++++++++++++++ crates/flycheck/src/lib.rs | 201 ++++-------------- crates/flycheck/src/test_runner.rs | 75 +++++++ crates/ide/src/lib.rs | 14 ++ crates/ide/src/test_explorer.rs | 135 ++++++++++++ crates/rust-analyzer/src/global_state.rs | 5 + .../src/hack_recover_crate_name.rs | 25 +++ .../src/handlers/notification.rs | 9 +- crates/rust-analyzer/src/handlers/request.rs | 65 ++++++ crates/rust-analyzer/src/lib.rs | 1 + crates/rust-analyzer/src/lsp/ext.rs | 102 +++++++++ crates/rust-analyzer/src/lsp/to_proto.rs | 26 +++ crates/rust-analyzer/src/main_loop.rs | 115 +++++++++- crates/stdx/src/process.rs | 5 + docs/dev/lsp-extensions.md | 102 ++++++++- editors/code/src/ctx.ts | 10 +- editors/code/src/lsp_ext.ts | 31 +++ editors/code/src/test_explorer.ts | 169 +++++++++++++++ 19 files changed, 1079 insertions(+), 168 deletions(-) create mode 100644 crates/flycheck/src/command.rs create mode 100644 crates/flycheck/src/test_runner.rs create mode 100644 crates/ide/src/test_explorer.rs create mode 100644 crates/rust-analyzer/src/hack_recover_crate_name.rs create mode 100644 editors/code/src/test_explorer.ts diff --git a/Cargo.toml b/Cargo.toml index e8e82914c7c4..eb3be72396c2 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 000000000000..091146a0010a --- /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; + fn from_eof() -> Option; +} + +struct CargoActor { + sender: Sender, + stdout: ChildStdout, + stderr: ChildStderr, +} + +impl CargoActor { + fn new(sender: Sender, 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 { + /// 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>, + pub(crate) receiver: Receiver, + program: OsString, + arguments: Vec, + current_dir: Option, +} + +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 { + pub(crate) fn spawn(mut command: Command) -> std::io::Result { + 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::>(); + 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 }) + } + + 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 8bcdca5bb828..f8efb5202220 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, + command_handle: Option>, } enum Event { RequestStateChange(StateChange), - CheckEvent(Option), + CheckEvent(Option), } 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>, - receiver: Receiver, - program: OsString, - arguments: Vec, - current_dir: Option, -} - -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 { - 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::>(); - 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 { + 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, - stdout: ChildStdout, - stderr: ChildStderr, -} - -impl CargoActor { - fn new(sender: Sender, 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 { + 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 000000000000..b7d966989b9b --- /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 { + 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 { + Some(CargoTestMessage::Finished) + } +} + +#[derive(Debug)] +pub struct CargoTestHandle { + handle: CommandHandle, +} + +// 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 { + 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 { + &self.handle.receiver + } +} diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index 4eb4acdf183c..59a7df14fd53 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> { + self.with_db(test_explorer::discover_test_roots) + } + + pub fn discover_tests_in_crate_by_test_id(&self, crate_id: &str) -> Cancellable> { + 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> { + 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> { 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 000000000000..2e741021ea8b --- /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, + pub file: Option, + pub text_range: Option, + pub runnable: Option, +} + +pub(crate) fn discover_test_roots(db: &RootDatabase) -> Vec { + 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 { + // 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 { + 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 { + 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 { + 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 8b8d0ebcb989..560410e3325d 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, pub(crate) last_flycheck_error: Option, + // Test explorer + pub(crate) test_run_session: Option, + // VFS pub(crate) loader: Handle, Receiver>, pub(crate) vfs: Arc)>>, @@ -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 000000000000..d7285653c5fa --- /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>> = OnceLock::new(); + +fn get_storage() -> MutexGuard<'static, FxHashMap> { + 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 { + 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 cf646a2e2828..ff213748b4ff 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::(()); + } + Ok(()) +} diff --git a/crates/rust-analyzer/src/handlers/request.rs b/crates/rust-analyzer/src/handlers/request.rs index 0e005975fbae..cc4333bd1dbd 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::(()); + } + // 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 { + 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 473ca991ad9b..175ffa622ff7 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 aa40728ce6cb..842e765d9d15 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, +} + +#[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, + pub text_document: Option, + pub range: Option, + pub runnable: Option, +} + +#[derive(Deserialize, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct DiscoverTestResults { + pub tests: Vec, + pub scope: Vec, +} + +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>, + pub exclude: Option>, +} + +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 481ebfefd4ee..f0bb7d8af3ab 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 72f6d0fde5fe..87da95c87e32 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)>), + 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::>(); + 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::>(); + 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::(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::ChangeTestStateParams { test_id, state }, + ); + } + flycheck::CargoTestMessage::Suite => (), + flycheck::CargoTestMessage::Finished => { + self.send_notification::(()); + 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::(handlers::handle_proc_macros_rebuild) .on_sync_mut::(handlers::handle_memory_usage) .on_sync_mut::(handlers::handle_shuffle_crate_graph) + .on_sync_mut::(handlers::handle_run_test) // Request handlers which are related to the user typing // are run on the main thread to reduce latency: .on_sync::(handlers::handle_join_lines) @@ -843,6 +944,7 @@ impl GlobalState { .on::(handlers::handle_view_file_text) .on::(handlers::handle_view_crate_graph) .on::(handlers::handle_view_item_tree) + .on::(handlers::handle_discover_test) .on::(handlers::handle_expand_macro) .on::(handlers::handle_parent_module) .on::(handlers::handle_runnables) @@ -906,6 +1008,7 @@ impl GlobalState { .on_sync_mut::(handlers::handle_cancel_flycheck)? .on_sync_mut::(handlers::handle_clear_flycheck)? .on_sync_mut::(handlers::handle_run_flycheck)? + .on_sync_mut::(handlers::handle_abort_run_test)? .finish(); Ok(()) } diff --git a/crates/stdx/src/process.rs b/crates/stdx/src/process.rs index bca0cbc36d1a..e6935f06b2ce 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, Vec)> { 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 f3100ee194e6..8d523d7baa3e 100644 --- a/docs/dev/lsp-extensions.md +++ b/docs/dev/lsp-extensions.md @@ -1,5 +1,5 @@