mirror of
https://github.com/rust-lang/rust-analyzer
synced 2024-12-26 13:03:31 +00:00
Add test explorer
This commit is contained in:
parent
916914418a
commit
44be2432f5
19 changed files with 1083 additions and 172 deletions
|
@ -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]
|
||||
|
|
156
crates/flycheck/src/command.rs
Normal file
156
crates/flycheck/src/command.rs
Normal file
|
@ -0,0 +1,156 @@
|
|||
//! Utilities for running a cargo command like `cargo check` or `cargo test` in a separate thread and
|
||||
//! parse its stdout/stderr.
|
||||
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
fmt, io,
|
||||
path::PathBuf,
|
||||
process::{ChildStderr, ChildStdout, Command, Stdio},
|
||||
};
|
||||
|
||||
use command_group::{CommandGroup, GroupChild};
|
||||
use crossbeam_channel::{unbounded, Receiver, Sender};
|
||||
use stdx::process::streaming_output;
|
||||
|
||||
/// Cargo output is structured as a one JSON per line. This trait abstracts parsing one line of
|
||||
/// cargo output into a Rust data type.
|
||||
pub(crate) trait ParseFromLine: Sized + Send + 'static {
|
||||
fn from_line(line: &str, error: &mut String) -> Option<Self>;
|
||||
fn from_eof() -> Option<Self>;
|
||||
}
|
||||
|
||||
struct CargoActor<T> {
|
||||
sender: Sender<T>,
|
||||
stdout: ChildStdout,
|
||||
stderr: ChildStderr,
|
||||
}
|
||||
|
||||
impl<T: ParseFromLine> CargoActor<T> {
|
||||
fn new(sender: Sender<T>, stdout: ChildStdout, stderr: ChildStderr) -> Self {
|
||||
CargoActor { sender, stdout, stderr }
|
||||
}
|
||||
|
||||
fn run(self) -> io::Result<(bool, String)> {
|
||||
// We manually read a line at a time, instead of using serde's
|
||||
// stream deserializers, because the deserializer cannot recover
|
||||
// from an error, resulting in it getting stuck, because we try to
|
||||
// be resilient against failures.
|
||||
//
|
||||
// Because cargo only outputs one JSON object per line, we can
|
||||
// simply skip a line if it doesn't parse, which just ignores any
|
||||
// erroneous output.
|
||||
|
||||
let mut stdout_errors = String::new();
|
||||
let mut stderr_errors = String::new();
|
||||
let mut read_at_least_one_stdout_message = false;
|
||||
let mut read_at_least_one_stderr_message = false;
|
||||
let process_line = |line: &str, error: &mut String| {
|
||||
// Try to deserialize a message from Cargo or Rustc.
|
||||
if let Some(t) = T::from_line(line, error) {
|
||||
self.sender.send(t).unwrap();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
let output = streaming_output(
|
||||
self.stdout,
|
||||
self.stderr,
|
||||
&mut |line| {
|
||||
if process_line(line, &mut stdout_errors) {
|
||||
read_at_least_one_stdout_message = true;
|
||||
}
|
||||
},
|
||||
&mut |line| {
|
||||
if process_line(line, &mut stderr_errors) {
|
||||
read_at_least_one_stderr_message = true;
|
||||
}
|
||||
},
|
||||
&mut || {
|
||||
if let Some(t) = T::from_eof() {
|
||||
self.sender.send(t).unwrap();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let read_at_least_one_message =
|
||||
read_at_least_one_stdout_message || read_at_least_one_stderr_message;
|
||||
let mut error = stdout_errors;
|
||||
error.push_str(&stderr_errors);
|
||||
match output {
|
||||
Ok(_) => Ok((read_at_least_one_message, error)),
|
||||
Err(e) => Err(io::Error::new(e.kind(), format!("{e:?}: {error}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct JodGroupChild(GroupChild);
|
||||
|
||||
impl Drop for JodGroupChild {
|
||||
fn drop(&mut self) {
|
||||
_ = self.0.kill();
|
||||
_ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a cargo process used for fly-checking.
|
||||
pub(crate) struct CommandHandle<T> {
|
||||
/// The handle to the actual cargo process. As we cannot cancel directly from with
|
||||
/// a read syscall dropping and therefore terminating the process is our best option.
|
||||
child: JodGroupChild,
|
||||
thread: stdx::thread::JoinHandle<io::Result<(bool, String)>>,
|
||||
pub(crate) receiver: Receiver<T>,
|
||||
program: OsString,
|
||||
arguments: Vec<OsString>,
|
||||
current_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl<T> fmt::Debug for CommandHandle<T> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommandHandle")
|
||||
.field("program", &self.program)
|
||||
.field("arguments", &self.arguments)
|
||||
.field("current_dir", &self.current_dir)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ParseFromLine> CommandHandle<T> {
|
||||
pub(crate) fn spawn(mut command: Command) -> std::io::Result<Self> {
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::null());
|
||||
let mut child = command.group_spawn().map(JodGroupChild)?;
|
||||
|
||||
let program = command.get_program().into();
|
||||
let arguments = command.get_args().map(|arg| arg.into()).collect::<Vec<OsString>>();
|
||||
let current_dir = command.get_current_dir().map(|arg| arg.to_path_buf());
|
||||
|
||||
let stdout = child.0.inner().stdout.take().unwrap();
|
||||
let stderr = child.0.inner().stderr.take().unwrap();
|
||||
|
||||
let (sender, receiver) = unbounded();
|
||||
let actor = CargoActor::<T>::new(sender, stdout, stderr);
|
||||
let thread = stdx::thread::Builder::new(stdx::thread::ThreadIntent::Worker)
|
||||
.name("CommandHandle".to_owned())
|
||||
.spawn(move || actor.run())
|
||||
.expect("failed to spawn thread");
|
||||
Ok(CommandHandle { program, arguments, current_dir, child, thread, receiver })
|
||||
}
|
||||
|
||||
pub(crate) fn cancel(mut self) {
|
||||
let _ = self.child.0.kill();
|
||||
let _ = self.child.0.wait();
|
||||
}
|
||||
|
||||
pub(crate) fn join(mut self) -> io::Result<()> {
|
||||
let _ = self.child.0.kill();
|
||||
let exit_status = self.child.0.wait()?;
|
||||
let (read_at_least_one_message, error) = self.thread.join()?;
|
||||
if read_at_least_one_message || exit_status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::Other, format!(
|
||||
"Cargo watcher failed, the command produced no valid metadata (exit code: {exit_status:?}):\n{error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,159 +450,40 @@ impl FlycheckActor {
|
|||
}
|
||||
}
|
||||
|
||||
struct JodGroupChild(GroupChild);
|
||||
|
||||
impl Drop for JodGroupChild {
|
||||
fn drop(&mut self) {
|
||||
_ = self.0.kill();
|
||||
_ = self.0.wait();
|
||||
}
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum CargoCheckMessage {
|
||||
CompilerArtifact(cargo_metadata::Artifact),
|
||||
Diagnostic(Diagnostic),
|
||||
}
|
||||
|
||||
/// A handle to a cargo process used for fly-checking.
|
||||
struct CommandHandle {
|
||||
/// The handle to the actual cargo process. As we cannot cancel directly from with
|
||||
/// a read syscall dropping and therefore terminating the process is our best option.
|
||||
child: JodGroupChild,
|
||||
thread: stdx::thread::JoinHandle<io::Result<(bool, String)>>,
|
||||
receiver: Receiver<CargoMessage>,
|
||||
program: OsString,
|
||||
arguments: Vec<OsString>,
|
||||
current_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CommandHandle {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("CommandHandle")
|
||||
.field("program", &self.program)
|
||||
.field("arguments", &self.arguments)
|
||||
.field("current_dir", &self.current_dir)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandHandle {
|
||||
fn spawn(mut command: Command) -> std::io::Result<CommandHandle> {
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::null());
|
||||
let mut child = command.group_spawn().map(JodGroupChild)?;
|
||||
|
||||
let program = command.get_program().into();
|
||||
let arguments = command.get_args().map(|arg| arg.into()).collect::<Vec<OsString>>();
|
||||
let current_dir = command.get_current_dir().map(|arg| arg.to_path_buf());
|
||||
|
||||
let stdout = child.0.inner().stdout.take().unwrap();
|
||||
let stderr = child.0.inner().stderr.take().unwrap();
|
||||
|
||||
let (sender, receiver) = unbounded();
|
||||
let actor = CargoActor::new(sender, stdout, stderr);
|
||||
let thread = stdx::thread::Builder::new(stdx::thread::ThreadIntent::Worker)
|
||||
.name("CommandHandle".to_owned())
|
||||
.spawn(move || actor.run())
|
||||
.expect("failed to spawn thread");
|
||||
Ok(CommandHandle { program, arguments, current_dir, child, thread, receiver })
|
||||
}
|
||||
|
||||
fn cancel(mut self) {
|
||||
let _ = self.child.0.kill();
|
||||
let _ = self.child.0.wait();
|
||||
}
|
||||
|
||||
fn join(mut self) -> io::Result<()> {
|
||||
let _ = self.child.0.kill();
|
||||
let exit_status = self.child.0.wait()?;
|
||||
let (read_at_least_one_message, error) = self.thread.join()?;
|
||||
if read_at_least_one_message || exit_status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(io::ErrorKind::Other, format!(
|
||||
"Cargo watcher failed, the command produced no valid metadata (exit code: {exit_status:?}):\n{error}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CargoActor {
|
||||
sender: Sender<CargoMessage>,
|
||||
stdout: ChildStdout,
|
||||
stderr: ChildStderr,
|
||||
}
|
||||
|
||||
impl CargoActor {
|
||||
fn new(sender: Sender<CargoMessage>, stdout: ChildStdout, stderr: ChildStderr) -> CargoActor {
|
||||
CargoActor { sender, stdout, stderr }
|
||||
}
|
||||
|
||||
fn run(self) -> io::Result<(bool, String)> {
|
||||
// We manually read a line at a time, instead of using serde's
|
||||
// stream deserializers, because the deserializer cannot recover
|
||||
// from an error, resulting in it getting stuck, because we try to
|
||||
// be resilient against failures.
|
||||
//
|
||||
// Because cargo only outputs one JSON object per line, we can
|
||||
// simply skip a line if it doesn't parse, which just ignores any
|
||||
// erroneous output.
|
||||
|
||||
let mut stdout_errors = String::new();
|
||||
let mut stderr_errors = String::new();
|
||||
let mut read_at_least_one_stdout_message = false;
|
||||
let mut read_at_least_one_stderr_message = false;
|
||||
let process_line = |line: &str, error: &mut String| {
|
||||
// Try to deserialize a message from Cargo or Rustc.
|
||||
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) {
|
||||
match message {
|
||||
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 => {
|
||||
self.sender.send(CargoMessage::CompilerArtifact(artifact)).unwrap();
|
||||
Some(CargoCheckMessage::CompilerArtifact(artifact))
|
||||
}
|
||||
cargo_metadata::Message::CompilerMessage(msg) => {
|
||||
self.sender.send(CargoMessage::Diagnostic(msg.message)).unwrap();
|
||||
Some(CargoCheckMessage::Diagnostic(msg.message))
|
||||
}
|
||||
_ => (),
|
||||
_ => None,
|
||||
},
|
||||
JsonMessage::Rustc(message) => {
|
||||
self.sender.send(CargoMessage::Diagnostic(message)).unwrap();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
JsonMessage::Rustc(message) => Some(CargoCheckMessage::Diagnostic(message)),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
None
|
||||
}
|
||||
},
|
||||
&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)]
|
||||
|
|
75
crates/flycheck/src/test_runner.rs
Normal file
75
crates/flycheck/src/test_runner.rs
Normal file
|
@ -0,0 +1,75 @@
|
|||
//! This module provides the functionality needed to run `cargo test` in a background
|
||||
//! thread and report the result of each test in a channel.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
use serde::Deserialize;
|
||||
use toolchain::Tool;
|
||||
|
||||
use crate::command::{CommandHandle, ParseFromLine};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "event", rename_all = "camelCase")]
|
||||
pub enum TestState {
|
||||
Started,
|
||||
Ok,
|
||||
Failed { stdout: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum CargoTestMessage {
|
||||
Test {
|
||||
name: String,
|
||||
#[serde(flatten)]
|
||||
state: TestState,
|
||||
},
|
||||
Suite,
|
||||
Finished,
|
||||
}
|
||||
|
||||
impl ParseFromLine for CargoTestMessage {
|
||||
fn from_line(line: &str, error: &mut String) -> Option<Self> {
|
||||
let mut deserializer = serde_json::Deserializer::from_str(line);
|
||||
deserializer.disable_recursion_limit();
|
||||
if let Ok(message) = CargoTestMessage::deserialize(&mut deserializer) {
|
||||
return Some(message);
|
||||
}
|
||||
|
||||
error.push_str(line);
|
||||
error.push('\n');
|
||||
None
|
||||
}
|
||||
|
||||
fn from_eof() -> Option<Self> {
|
||||
Some(CargoTestMessage::Finished)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CargoTestHandle {
|
||||
handle: CommandHandle<CargoTestMessage>,
|
||||
}
|
||||
|
||||
// Example of a cargo test command:
|
||||
// cargo test -- module::func -Z unstable-options --format=json
|
||||
|
||||
impl CargoTestHandle {
|
||||
pub fn new(path: Option<&str>) -> std::io::Result<Self> {
|
||||
let mut cmd = Command::new(Tool::Cargo.path());
|
||||
cmd.env("RUSTC_BOOTSTRAP", "1");
|
||||
cmd.arg("test");
|
||||
cmd.arg("--");
|
||||
if let Some(path) = path {
|
||||
cmd.arg(path);
|
||||
}
|
||||
cmd.args(["-Z", "unstable-options"]);
|
||||
cmd.arg("--format=json");
|
||||
Ok(Self { handle: CommandHandle::spawn(cmd)? })
|
||||
}
|
||||
|
||||
pub fn receiver(&self) -> &Receiver<CargoTestMessage> {
|
||||
&self.handle.receiver
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
|
|
135
crates/ide/src/test_explorer.rs
Normal file
135
crates/ide/src/test_explorer.rs
Normal file
|
@ -0,0 +1,135 @@
|
|||
//! Discovers tests
|
||||
|
||||
use hir::{Crate, Module, ModuleDef, Semantics};
|
||||
use ide_db::{
|
||||
base_db::{CrateGraph, CrateId, FileId, SourceDatabase},
|
||||
RootDatabase,
|
||||
};
|
||||
use syntax::TextRange;
|
||||
|
||||
use crate::{navigation_target::ToNav, runnables::runnable_fn, Runnable, TryToNav};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TestItemKind {
|
||||
Crate,
|
||||
Module,
|
||||
Function,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TestItem {
|
||||
pub id: String,
|
||||
pub kind: TestItemKind,
|
||||
pub label: String,
|
||||
pub parent: Option<String>,
|
||||
pub file: Option<FileId>,
|
||||
pub text_range: Option<TextRange>,
|
||||
pub runnable: Option<Runnable>,
|
||||
}
|
||||
|
||||
pub(crate) fn discover_test_roots(db: &RootDatabase) -> Vec<TestItem> {
|
||||
let crate_graph = db.crate_graph();
|
||||
crate_graph
|
||||
.iter()
|
||||
.filter(|&id| crate_graph[id].origin.is_local())
|
||||
.filter_map(|id| Some(crate_graph[id].display_name.as_ref()?.to_string()))
|
||||
.map(|id| TestItem {
|
||||
kind: TestItemKind::Crate,
|
||||
label: id.clone(),
|
||||
id,
|
||||
parent: None,
|
||||
file: None,
|
||||
text_range: None,
|
||||
runnable: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_crate_by_id(crate_graph: &CrateGraph, crate_id: &str) -> Option<CrateId> {
|
||||
// here, we use display_name as the crate id. This is not super ideal, but it works since we
|
||||
// only show tests for the local crates.
|
||||
crate_graph.iter().find(|&id| {
|
||||
crate_graph[id].origin.is_local()
|
||||
&& crate_graph[id].display_name.as_ref().is_some_and(|x| x.to_string() == crate_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn discover_tests_in_module(db: &RootDatabase, module: Module, prefix_id: String) -> Vec<TestItem> {
|
||||
let sema = Semantics::new(db);
|
||||
|
||||
let mut r = vec![];
|
||||
for c in module.children(db) {
|
||||
let module_name =
|
||||
c.name(db).as_ref().and_then(|n| n.as_str()).unwrap_or("[mod without name]").to_owned();
|
||||
let module_id = format!("{prefix_id}::{module_name}");
|
||||
let module_children = discover_tests_in_module(db, c, module_id.clone());
|
||||
if !module_children.is_empty() {
|
||||
let nav = c.to_nav(db).call_site;
|
||||
r.push(TestItem {
|
||||
id: module_id,
|
||||
kind: TestItemKind::Module,
|
||||
label: module_name,
|
||||
parent: Some(prefix_id.clone()),
|
||||
file: Some(nav.file_id),
|
||||
text_range: Some(nav.focus_or_full_range()),
|
||||
runnable: None,
|
||||
});
|
||||
r.extend(module_children);
|
||||
}
|
||||
}
|
||||
for def in module.declarations(db) {
|
||||
let ModuleDef::Function(f) = def else {
|
||||
continue;
|
||||
};
|
||||
if !f.is_test(db) {
|
||||
continue;
|
||||
}
|
||||
let nav = f.try_to_nav(db).map(|r| r.call_site);
|
||||
let fn_name = f.name(db).as_str().unwrap_or("[function without name]").to_owned();
|
||||
r.push(TestItem {
|
||||
id: format!("{prefix_id}::{fn_name}"),
|
||||
kind: TestItemKind::Function,
|
||||
label: fn_name,
|
||||
parent: Some(prefix_id.clone()),
|
||||
file: nav.as_ref().map(|n| n.file_id),
|
||||
text_range: nav.as_ref().map(|n| n.focus_or_full_range()),
|
||||
runnable: runnable_fn(&sema, f),
|
||||
});
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
pub(crate) fn discover_tests_in_crate_by_test_id(
|
||||
db: &RootDatabase,
|
||||
crate_test_id: &str,
|
||||
) -> Vec<TestItem> {
|
||||
let crate_graph = db.crate_graph();
|
||||
let Some(crate_id) = find_crate_by_id(&crate_graph, crate_test_id) else {
|
||||
return vec![];
|
||||
};
|
||||
discover_tests_in_crate(db, crate_id)
|
||||
}
|
||||
|
||||
pub(crate) fn discover_tests_in_crate(db: &RootDatabase, crate_id: CrateId) -> Vec<TestItem> {
|
||||
let crate_graph = db.crate_graph();
|
||||
if !crate_graph[crate_id].origin.is_local() {
|
||||
return vec![];
|
||||
}
|
||||
let Some(crate_test_id) = &crate_graph[crate_id].display_name else {
|
||||
return vec![];
|
||||
};
|
||||
let crate_test_id = crate_test_id.to_string();
|
||||
let crate_id: Crate = crate_id.into();
|
||||
let module = crate_id.root_module();
|
||||
let mut r = vec![TestItem {
|
||||
id: crate_test_id.clone(),
|
||||
kind: TestItemKind::Crate,
|
||||
label: crate_test_id.clone(),
|
||||
parent: None,
|
||||
file: None,
|
||||
text_range: None,
|
||||
runnable: None,
|
||||
}];
|
||||
r.extend(discover_tests_in_module(db, module, crate_test_id));
|
||||
r
|
||||
}
|
|
@ -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,
|
||||
|
|
25
crates/rust-analyzer/src/hack_recover_crate_name.rs
Normal file
25
crates/rust-analyzer/src/hack_recover_crate_name.rs
Normal file
|
@ -0,0 +1,25 @@
|
|||
//! Currently cargo does not emit crate name in the `cargo test --format=json`, which needs to be changed. This
|
||||
//! module contains a way to recover crate names in a very hacky and wrong way.
|
||||
|
||||
// FIXME(hack_recover_crate_name): Remove this module.
|
||||
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
use ide_db::FxHashMap;
|
||||
|
||||
static STORAGE: OnceLock<Mutex<FxHashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
fn get_storage() -> MutexGuard<'static, FxHashMap<String, String>> {
|
||||
STORAGE.get_or_init(|| Mutex::new(FxHashMap::default())).lock().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_name(name_with_crate: String) {
|
||||
let Some((_, name_without_crate)) = name_with_crate.split_once("::") else {
|
||||
return;
|
||||
};
|
||||
get_storage().insert(name_without_crate.to_owned(), name_with_crate);
|
||||
}
|
||||
|
||||
pub(crate) fn lookup_name(name_without_crate: String) -> Option<String> {
|
||||
get_storage().get(&name_without_crate).cloned()
|
||||
}
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
169
editors/code/src/test_explorer.ts
Normal file
169
editors/code/src/test_explorer.ts
Normal file
|
@ -0,0 +1,169 @@
|
|||
import * as vscode from "vscode";
|
||||
import type * as lc from "vscode-languageclient/node";
|
||||
import * as ra from "./lsp_ext";
|
||||
|
||||
import type { Ctx } from "./ctx";
|
||||
import { startDebugSession } from "./debug";
|
||||
|
||||
export const prepareTestExplorer = (
|
||||
ctx: Ctx,
|
||||
testController: vscode.TestController,
|
||||
client: lc.LanguageClient,
|
||||
) => {
|
||||
let currentTestRun: vscode.TestRun | undefined;
|
||||
let idToTestMap: Map<string, vscode.TestItem> = new Map();
|
||||
const idToRunnableMap: Map<string, ra.Runnable> = new Map();
|
||||
|
||||
testController.createRunProfile(
|
||||
"Run Tests",
|
||||
vscode.TestRunProfileKind.Run,
|
||||
async (request: vscode.TestRunRequest, cancelToken: vscode.CancellationToken) => {
|
||||
if (currentTestRun) {
|
||||
await client.sendNotification(ra.abortRunTest);
|
||||
while (currentTestRun) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
currentTestRun = testController.createTestRun(request);
|
||||
cancelToken.onCancellationRequested(async () => {
|
||||
await client.sendNotification(ra.abortRunTest);
|
||||
});
|
||||
const include = request.include?.map((x) => x.id);
|
||||
const exclude = request.exclude?.map((x) => x.id);
|
||||
await client.sendRequest(ra.runTest, { include, exclude });
|
||||
},
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
testController.createRunProfile(
|
||||
"Debug Tests",
|
||||
vscode.TestRunProfileKind.Debug,
|
||||
async (request: vscode.TestRunRequest) => {
|
||||
if (request.include?.length !== 1 || request.exclude?.length !== 0) {
|
||||
await vscode.window.showErrorMessage("You can debug only one test at a time");
|
||||
return;
|
||||
}
|
||||
const id = request.include[0]!.id;
|
||||
const runnable = idToRunnableMap.get(id);
|
||||
if (!runnable) {
|
||||
await vscode.window.showErrorMessage("You can debug only one test at a time");
|
||||
return;
|
||||
}
|
||||
await startDebugSession(ctx, runnable);
|
||||
},
|
||||
true,
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
|
||||
const addTest = (item: ra.TestItem) => {
|
||||
const parentList = item.parent
|
||||
? idToTestMap.get(item.parent)!.children
|
||||
: testController.items;
|
||||
const oldTest = parentList.get(item.id);
|
||||
const uri = item.textDocument?.uri ? vscode.Uri.parse(item.textDocument?.uri) : undefined;
|
||||
const range =
|
||||
item.range &&
|
||||
new vscode.Range(
|
||||
new vscode.Position(item.range.start.line, item.range.start.character),
|
||||
new vscode.Position(item.range.end.line, item.range.end.character),
|
||||
);
|
||||
if (oldTest) {
|
||||
if (oldTest.uri?.toString() === uri?.toString()) {
|
||||
oldTest.range = range;
|
||||
return;
|
||||
}
|
||||
parentList.delete(item.id);
|
||||
}
|
||||
const iconToVscodeMap = {
|
||||
package: "package",
|
||||
module: "symbol-module",
|
||||
test: "beaker",
|
||||
};
|
||||
const test = testController.createTestItem(
|
||||
item.id,
|
||||
`$(${iconToVscodeMap[item.icon]}) ${item.label}`,
|
||||
uri,
|
||||
);
|
||||
test.range = range;
|
||||
test.canResolveChildren = item.canResolveChildren;
|
||||
idToTestMap.set(item.id, test);
|
||||
if (item.runnable) {
|
||||
idToRunnableMap.set(item.id, item.runnable);
|
||||
}
|
||||
parentList.add(test);
|
||||
};
|
||||
|
||||
const addTestGroup = (testsAndScope: ra.DiscoverTestResults) => {
|
||||
const { tests, scope } = testsAndScope;
|
||||
const testSet: Set<string> = new Set();
|
||||
for (const test of tests) {
|
||||
addTest(test);
|
||||
testSet.add(test.id);
|
||||
}
|
||||
// FIXME(hack_recover_crate_name): We eagerly resolve every test if we got a lazy top level response (detected
|
||||
// by `!scope`). ctx is not a good thing and wastes cpu and memory unnecessarily, so we should remove it.
|
||||
if (!scope) {
|
||||
for (const test of tests) {
|
||||
void testController.resolveHandler!(idToTestMap.get(test.id));
|
||||
}
|
||||
}
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
const recursivelyRemove = (tests: vscode.TestItemCollection) => {
|
||||
for (const [testId, _] of tests) {
|
||||
if (!testSet.has(testId)) {
|
||||
tests.delete(testId);
|
||||
} else {
|
||||
recursivelyRemove(tests.get(testId)!.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const root of scope) {
|
||||
recursivelyRemove(idToTestMap.get(root)!.children);
|
||||
}
|
||||
};
|
||||
|
||||
ctx.pushClientCleanup(
|
||||
client.onNotification(ra.discoveredTests, (results) => {
|
||||
addTestGroup(results);
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.pushClientCleanup(
|
||||
client.onNotification(ra.endRunTest, () => {
|
||||
currentTestRun!.end();
|
||||
currentTestRun = undefined;
|
||||
}),
|
||||
);
|
||||
|
||||
ctx.pushClientCleanup(
|
||||
client.onNotification(ra.changeTestState, (results) => {
|
||||
const test = idToTestMap.get(results.testId)!;
|
||||
if (results.state.tag === "failed") {
|
||||
currentTestRun!.failed(test, new vscode.TestMessage(results.state.message));
|
||||
} else if (results.state.tag === "passed") {
|
||||
currentTestRun!.passed(test);
|
||||
} else if (results.state.tag === "started") {
|
||||
currentTestRun!.started(test);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
testController.resolveHandler = async (item) => {
|
||||
const results = await client.sendRequest(ra.discoverTest, { testId: item?.id });
|
||||
addTestGroup(results);
|
||||
};
|
||||
|
||||
testController.refreshHandler = async () => {
|
||||
testController.items.forEach((t) => {
|
||||
testController.items.delete(t.id);
|
||||
});
|
||||
idToTestMap = new Map();
|
||||
await testController.resolveHandler!(undefined);
|
||||
};
|
||||
};
|
Loading…
Reference in a new issue