From bcd34c83ff7f2539e86636bc6038fe92461384ab Mon Sep 17 00:00:00 2001 From: ShuiRuTian <158983297@qq.com> Date: Thu, 1 Jun 2023 23:24:59 +0800 Subject: [PATCH] Implement test explorer --- Cargo.lock | 1 + crates/ide-db/src/search.rs | 2 +- crates/ide/src/annotations.rs | 164 ++-- crates/ide/src/lib.rs | 5 + crates/ide/src/runnables.rs | 182 +++- crates/ide/src/test_items.rs | 32 + crates/project-model/src/cargo_workspace.rs | 26 +- crates/rust-analyzer/Cargo.toml | 1 + crates/rust-analyzer/src/handlers/request.rs | 38 + crates/rust-analyzer/src/lsp/ext.rs | 22 + crates/rust-analyzer/src/main_loop.rs | 2 + docs/dev/lsp-extensions.md | 80 ++ docs/user/generated_config.adoc | 5 + editors/code/package.json | 5 + editors/code/src/config.ts | 4 + editors/code/src/ctx.ts | 1 + editors/code/src/debug.ts | 24 +- editors/code/src/lsp_ext.ts | 13 + editors/code/src/main.ts | 22 +- editors/code/src/tasks.ts | 2 +- editors/code/src/test_explorer/README.md | 232 +++++ .../code/src/test_explorer/RunnableFacde.ts | 223 +++++ .../src/test_explorer/RustcOutputAnalyzer.ts | 330 +++++++ .../test_explorer/TestItemControllerHelper.ts | 28 + editors/code/src/test_explorer/api_helper.ts | 74 ++ .../src/test_explorer/discover_and_update.ts | 846 ++++++++++++++++++ editors/code/src/test_explorer/index.ts | 54 ++ .../code/src/test_explorer/run_or_debug.ts | 256 ++++++ .../code/src/test_explorer/test_model_tree.ts | 552 ++++++++++++ editors/code/src/toolchain.ts | 68 ++ editors/code/src/util.ts | 10 +- 31 files changed, 3191 insertions(+), 113 deletions(-) create mode 100644 crates/ide/src/test_items.rs create mode 100644 editors/code/src/test_explorer/README.md create mode 100644 editors/code/src/test_explorer/RunnableFacde.ts create mode 100644 editors/code/src/test_explorer/RustcOutputAnalyzer.ts create mode 100644 editors/code/src/test_explorer/TestItemControllerHelper.ts create mode 100644 editors/code/src/test_explorer/api_helper.ts create mode 100644 editors/code/src/test_explorer/discover_and_update.ts create mode 100644 editors/code/src/test_explorer/index.ts create mode 100644 editors/code/src/test_explorer/run_or_debug.ts create mode 100644 editors/code/src/test_explorer/test_model_tree.ts diff --git a/Cargo.lock b/Cargo.lock index fa7b6a2fa378..c2bb44a9d93f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1569,6 +1569,7 @@ version = "0.0.0" dependencies = [ "always-assert", "anyhow", + "cargo_metadata", "cfg", "crossbeam-channel", "dissimilar", diff --git a/crates/ide-db/src/search.rs b/crates/ide-db/src/search.rs index 9c4f0ac8c9fc..52d65942d996 100644 --- a/crates/ide-db/src/search.rs +++ b/crates/ide-db/src/search.rs @@ -93,7 +93,7 @@ impl SearchScope { } /// Build a search scope spanning the entire crate graph of files. - fn crate_graph(db: &RootDatabase) -> SearchScope { + pub fn crate_graph(db: &RootDatabase) -> SearchScope { let mut entries = IntMap::default(); let graph = db.crate_graph(); diff --git a/crates/ide/src/annotations.rs b/crates/ide/src/annotations.rs index fb79b5dc211a..dade8a371271 100644 --- a/crates/ide/src/annotations.rs +++ b/crates/ide/src/annotations.rs @@ -744,89 +744,109 @@ mod tests { } "#, expect![[r#" - [ - Annotation { - range: 3..7, - kind: Runnable( - Runnable { - use_name_in_title: false, - nav: NavigationTarget { - file_id: FileId( - 0, - ), - full_range: 0..12, - focus_range: 3..7, - name: "main", - kind: Function, - }, - kind: Bin, - cfg: None, + [ + Annotation { + range: 3..7, + kind: Runnable( + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..12, + focus_range: 3..7, + name: "main", + kind: Function, }, - ), - }, - Annotation { - range: 18..23, - kind: Runnable( - Runnable { - use_name_in_title: false, - nav: NavigationTarget { - file_id: FileId( - 0, - ), - full_range: 14..64, - focus_range: 18..23, - name: "tests", - kind: Module, - description: "mod tests", - }, - kind: TestMod { - path: "tests", - }, - cfg: None, + kind: Bin, + cfg: None, + }, + ), + }, + Annotation { + range: 18..23, + kind: Runnable( + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 14..64, + focus_range: 18..23, + name: "tests", + kind: Module, + description: "mod tests", }, - ), - }, - Annotation { - range: 45..57, - kind: Runnable( - Runnable { - use_name_in_title: false, - nav: NavigationTarget { - file_id: FileId( - 0, - ), - full_range: 30..62, - focus_range: 45..57, - name: "my_cool_test", - kind: Function, - }, - kind: Test { - test_id: Path( - "tests::my_cool_test", - ), - attr: TestAttr { - ignore: false, - }, + kind: TestMod { + path: "tests", + }, + cfg: None, + }, + ), + }, + Annotation { + range: 45..57, + kind: Runnable( + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 30..62, + focus_range: 45..57, + name: "my_cool_test", + kind: Function, + }, + kind: Test { + test_id: Path( + "tests::my_cool_test", + ), + attr: TestAttr { + ignore: false, }, - cfg: None, }, - ), - }, - Annotation { - range: 3..7, - kind: HasReferences { - pos: FilePosition { + cfg: None, + }, + ), + }, + Annotation { + range: 0..77, + kind: Runnable( + Runnable { + use_name_in_title: false, + nav: NavigationTarget { file_id: FileId( 0, ), - offset: 3, + full_range: 0..77, + name: "", + kind: Module, }, - data: Some( - [], + kind: TestMod { + path: "", + }, + cfg: None, + }, + ), + }, + Annotation { + range: 3..7, + kind: HasReferences { + pos: FilePosition { + file_id: FileId( + 0, ), + offset: 3, }, + data: Some( + [], + ), }, - ] + }, + ] "#]], ); } diff --git a/crates/ide/src/lib.rs b/crates/ide/src/lib.rs index aee03d218adf..e2d4b2fa2326 100644 --- a/crates/ide/src/lib.rs +++ b/crates/ide/src/lib.rs @@ -47,6 +47,7 @@ mod parent_module; mod references; mod rename; mod runnables; +mod test_items; mod ssr; mod static_index; mod status; @@ -552,6 +553,10 @@ impl Analysis { self.with_db(|db| runnables::runnables(db, file_id)) } + pub fn test_runnables_in_file(&self, file_id: FileId) -> Cancellable> { + self.with_db(|db| test_items::test_runnables_in_file(db, file_id)) + } + /// Returns the set of tests for the given file position. pub fn related_tests( &self, diff --git a/crates/ide/src/runnables.rs b/crates/ide/src/runnables.rs index 2d528c642558..2793684d6aae 100644 --- a/crates/ide/src/runnables.rs +++ b/crates/ide/src/runnables.rs @@ -296,7 +296,7 @@ fn parent_test_module(sema: &Semantics<'_, RootDatabase>, fn_def: &ast::Fn) -> O let module = ast::Module::cast(node)?; let module = sema.to_def(&module)?; - if has_test_function_or_multiple_test_submodules(sema, &module) { + if has_test_function_recursively(sema, &module) { Some(module) } else { None @@ -346,7 +346,7 @@ pub(crate) fn runnable_mod( sema: &Semantics<'_, RootDatabase>, def: hir::Module, ) -> Option { - if !has_test_function_or_multiple_test_submodules(sema, &def) { + if !has_test_function_recursively(sema, &def) { return None; } let path = def @@ -393,7 +393,7 @@ fn runnable_mod_outline_definition( sema: &Semantics<'_, RootDatabase>, def: hir::Module, ) -> Option { - if !has_test_function_or_multiple_test_submodules(sema, &def) { + if !has_test_function_recursively(sema, &def) { return None; } let path = def @@ -520,14 +520,16 @@ fn has_runnable_doc_test(attrs: &hir::Attrs) -> bool { }) } +// Argue: +// Should we return when `number_of_test_submodules > 0` or `number_of_test_submodules > 1`? +// Support `> 1`: // We could create runnables for modules with number_of_test_submodules > 0, // but that bloats the runnables for no real benefit, since all tests can be run by the submodule already -fn has_test_function_or_multiple_test_submodules( - sema: &Semantics<'_, RootDatabase>, - module: &hir::Module, -) -> bool { - let mut number_of_test_submodules = 0; - +// Support `> 0`: +// This will be helpful to rebuild the test item tree for VSCode, although it might should use another function or API. +// A bit faster +// Tell that there are some tests in the module when there is only declaration "mod SomeModule;" +fn has_test_function_recursively(sema: &Semantics<'_, RootDatabase>, module: &hir::Module) -> bool { for item in module.declarations(sema.db) { match item { hir::ModuleDef::Function(f) => { @@ -538,15 +540,15 @@ fn has_test_function_or_multiple_test_submodules( } } hir::ModuleDef::Module(submodule) => { - if has_test_function_or_multiple_test_submodules(sema, &submodule) { - number_of_test_submodules += 1; + if has_test_function_recursively(sema, &submodule) { + return true; } } _ => (), } } - number_of_test_submodules > 1 + return false; } #[cfg(test)] @@ -1252,9 +1254,24 @@ mod test_mod { fn test_foo1() {} } "#, - &[TestMod, Test], + &[TestMod, TestMod, Test], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..52, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, Runnable { use_name_in_title: false, nav: NavigationTarget { @@ -1325,9 +1342,41 @@ mod root_tests { mod nested_tests_4 {} } "#, - &[TestMod, TestMod, Test, Test, TestMod, Test], + &[TestMod, TestMod, TestMod, TestMod, Test, Test, TestMod, Test], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..353, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 1..352, + focus_range: 5..15, + name: "root_tests", + kind: Module, + description: "mod root_tests", + }, + kind: TestMod { + path: "root_tests", + }, + cfg: None, + }, Runnable { use_name_in_title: false, nav: NavigationTarget { @@ -1776,9 +1825,24 @@ macro_rules! foo { } foo!(); "#, - &[Test, Test, Test, TestMod], + &[TestMod, Test, Test, Test, TestMod], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..218, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, Runnable { use_name_in_title: true, nav: NavigationTarget { @@ -1873,9 +1937,42 @@ mod tests { fn t() {} } "#, - &[], + &[TestMod, TestMod], expect![[r#" - [] + [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..8, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 1..7, + focus_range: 5..6, + name: "m", + kind: Module, + description: "mod m", + }, + kind: TestMod { + path: "m", + }, + cfg: None, + }, + ] "#]], ); } @@ -1893,9 +1990,24 @@ fn t0() {} #[test] fn t1() {} "#, - &[TestMod], + &[TestMod, TestMod], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..8, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, Runnable { use_name_in_title: false, nav: NavigationTarget { @@ -2011,9 +2123,24 @@ mod module { fn t1() {} } "#, - &[TestMod, Test, Test], + &[TestMod, TestMod, Test, Test], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..95, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, Runnable { use_name_in_title: true, nav: NavigationTarget { @@ -2560,9 +2687,24 @@ mod r#mod { impl r#trait for r#struct {} } "#, - &[TestMod, Test, DocTest, DocTest, DocTest, DocTest, DocTest, DocTest], + &[TestMod, TestMod, Test, DocTest, DocTest, DocTest, DocTest, DocTest, DocTest], expect![[r#" [ + Runnable { + use_name_in_title: false, + nav: NavigationTarget { + file_id: FileId( + 0, + ), + full_range: 0..462, + name: "", + kind: Module, + }, + kind: TestMod { + path: "", + }, + cfg: None, + }, Runnable { use_name_in_title: false, nav: NavigationTarget { diff --git a/crates/ide/src/test_items.rs b/crates/ide/src/test_items.rs new file mode 100644 index 000000000000..582072944243 --- /dev/null +++ b/crates/ide/src/test_items.rs @@ -0,0 +1,32 @@ +// This mod is mainly to support vscode native test extension +// please reference: https://code.visualstudio.com/api/extension-guides/testing +// It's a pretty rough implementation for now, reuse a lot of logic from runnable. +use ide_db::{base_db::FileId, RootDatabase}; + +use crate::{runnables::runnables, Runnable, RunnableKind}; + +// Feature: Test-Like Runnables +// +// Return runnables which would be shown in test explorer +// And there is no entry for editor, this method should only be called by test explorer though API directly +pub(crate) fn test_runnables_in_file(db: &RootDatabase, file_id: FileId) -> Vec { + // REVIEW: We could also filter in the client side, which is better? + return test_runnables_in_file_iter(db, file_id).collect(); +} + +fn test_runnables_in_file_iter( + db: &RootDatabase, + file_id: FileId, +) -> impl Iterator { + let all_runnables = runnables(db, file_id); + let tests = all_runnables.into_iter().filter(is_test_runnable); + return tests; + + fn is_test_runnable(runnable: &Runnable) -> bool { + match runnable.kind { + RunnableKind::Test { .. } => true, + RunnableKind::TestMod { .. } => true, + _ => false, + } + } +} diff --git a/crates/project-model/src/cargo_workspace.rs b/crates/project-model/src/cargo_workspace.rs index e47808a2cc9f..34145e64b348 100644 --- a/crates/project-model/src/cargo_workspace.rs +++ b/crates/project-model/src/cargo_workspace.rs @@ -26,14 +26,32 @@ use crate::{CfgOverrides, InvocationStrategy}; /// /// We use absolute paths here, `cargo metadata` guarantees to always produce /// abs paths. -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone)] pub struct CargoWorkspace { packages: Arena, targets: Arena, workspace_root: AbsPathBuf, target_directory: AbsPathBuf, + // Hack, this should be an implmentation detail, however, + // sometimes it's useful to let the client know the project + // structure. + // This property should only be used as JSON + pub origin_metadata: cargo_metadata::Metadata, +} + +impl PartialEq for CargoWorkspace { + fn eq(&self, other: &Self) -> bool { + self.packages == other.packages + && self.targets == other.targets + && self.workspace_root == other.workspace_root + // Do not compare the origin data + // It's only used to be transfer as JSON + // && self.origin_metadata == other.origin_metadata + } } +impl Eq for CargoWorkspace {} + impl ops::Index for CargoWorkspace { type Output = PackageData; fn index(&self, index: Package) -> &PackageData { @@ -305,9 +323,11 @@ impl CargoWorkspace { let mut pkg_by_id = FxHashMap::default(); let mut packages = Arena::default(); let mut targets = Arena::default(); - + // let tmp = Box::new(meta); let ws_members = &meta.workspace_members; + let origin_metadata = meta.clone(); + meta.packages.sort_by(|a, b| a.id.cmp(&b.id)); for meta_pkg in meta.packages { let cargo_metadata::Package { @@ -391,7 +411,7 @@ impl CargoWorkspace { let target_directory = AbsPathBuf::assert(PathBuf::from(meta.target_directory.into_os_string())); - CargoWorkspace { packages, targets, workspace_root, target_directory } + CargoWorkspace { packages, targets, workspace_root, target_directory, origin_metadata } } pub fn packages(&self) -> impl Iterator + ExactSizeIterator + '_ { diff --git a/crates/rust-analyzer/Cargo.toml b/crates/rust-analyzer/Cargo.toml index 0a5412c638c3..58d90b63c9c8 100644 --- a/crates/rust-analyzer/Cargo.toml +++ b/crates/rust-analyzer/Cargo.toml @@ -20,6 +20,7 @@ path = "src/bin/main.rs" [dependencies] anyhow = "1.0.62" +cargo_metadata = "0.15.0" crossbeam-channel = "0.5.5" dissimilar = "1.0.4" itertools = "0.10.5" diff --git a/crates/rust-analyzer/src/handlers/request.rs b/crates/rust-analyzer/src/handlers/request.rs index b8a1a39be193..b80e197494b0 100644 --- a/crates/rust-analyzer/src/handlers/request.rs +++ b/crates/rust-analyzer/src/handlers/request.rs @@ -15,6 +15,7 @@ use ide::{ Runnable, RunnableKind, SingleResolve, SourceChange, TextEdit, }; use ide_db::SymbolKind; +use itertools::Itertools; use lsp_server::ErrorCode; use lsp_types::{ CallHierarchyIncomingCall, CallHierarchyIncomingCallsParams, CallHierarchyItem, @@ -822,6 +823,43 @@ pub(crate) fn handle_runnables( Ok(res) } +pub(crate) fn handle_cargo_workspaces( + snap: GlobalStateSnapshot, + _: (), +) -> anyhow::Result> { + let _p = profile::span("cargo_workspaces"); + let res = snap + .workspaces + .iter() + .filter_map(|workspace| { + if let ProjectWorkspace::Cargo { cargo, .. } = workspace { + Some(cargo.origin_metadata.clone()) + } else { + None + } + }) + .collect_vec(); + Ok(res) +} + +pub(crate) fn handle_test_runnables_in_file( + snap: GlobalStateSnapshot, + params: lsp_ext::TestRunnablesInFileParams, +) -> anyhow::Result> { + let _p = profile::span("handle_test_runnables_in_file"); + + let file_id = from_proto::file_id(&snap, ¶ms.text_document.uri)?; + let test_runnables = snap.analysis.test_runnables_in_file(file_id)?; + + let mut res = Vec::new(); + for runnable in test_runnables { + let runnable = to_proto::runnable(&snap, runnable)?; + res.push(runnable); + } + + Ok(res) +} + fn should_skip_for_offset(runnable: &Runnable, offset: Option) -> bool { match offset { None => false, diff --git a/crates/rust-analyzer/src/lsp/ext.rs b/crates/rust-analyzer/src/lsp/ext.rs index ad56899163d3..032ddbbabaa6 100644 --- a/crates/rust-analyzer/src/lsp/ext.rs +++ b/crates/rust-analyzer/src/lsp/ext.rs @@ -304,6 +304,12 @@ pub struct RunnablesParams { pub position: Option, } +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TestRunnablesInFileParams { + pub text_document: TextDocumentIdentifier, +} + #[derive(Deserialize, Serialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Runnable { @@ -350,6 +356,22 @@ pub struct TestInfo { pub runnable: Runnable, } +pub enum CargoWorkspaces {} + +impl Request for CargoWorkspaces { + type Params = (); + type Result = Vec; + const METHOD: &'static str = "rust-analyzer/cargoWorkspaces"; +} + +pub enum TestRunnablesInFile {} + +impl Request for TestRunnablesInFile { + type Params = TestRunnablesInFileParams; + type Result = Vec; + const METHOD: &'static str = "rust-analyzer/testRunnablesInFile"; +} + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct InlayHintsParams { diff --git a/crates/rust-analyzer/src/main_loop.rs b/crates/rust-analyzer/src/main_loop.rs index cdf41c955d26..44107b097049 100644 --- a/crates/rust-analyzer/src/main_loop.rs +++ b/crates/rust-analyzer/src/main_loop.rs @@ -755,6 +755,8 @@ impl GlobalState { .on::(handlers::handle_expand_macro) .on::(handlers::handle_parent_module) .on::(handlers::handle_runnables) + .on::(handlers::handle_cargo_workspaces) + .on::(handlers::handle_test_runnables_in_file) .on::(handlers::handle_related_tests) .on::(handlers::handle_code_action) .on::(handlers::handle_code_action_resolve) diff --git a/docs/dev/lsp-extensions.md b/docs/dev/lsp-extensions.md index 0801e988f5ce..9db2e1fb8edb 100644 --- a/docs/dev/lsp-extensions.md +++ b/docs/dev/lsp-extensions.md @@ -730,6 +730,86 @@ interface TestInfo { } ``` +## CargoWorkspaces + +This request is sent from client to server to get the cargo workspaces info, should be almost same as `cargo workspaces`. + +**Method:** `rust-analyzer/cargoWorkspaces` + +**Request:** `null` + +**Response:** `CargoMetadata[]` + +```typescript +/** + * The result of `cargo metadata` + * + * This is only part of the whole structure + */ +export interface CargoMetadata { + workspace_root: string; + workspace_members: string[]; + packages: CargoPackageMetadata[]; +} + +/** + * The value of property "cargo metadata".packages[x].targets[y].kind[0] + */ +export enum CargoTargetKind { + Lib = "lib", + Binary = "bin", + Test = "test", + Example = "example", + Bench = 'bench', + /** + * Refer "https://doc.rust-lang.org/cargo/reference/build-scripts.html" + * + * "build.rs" is a special target internally + */ + BuildScript = "custom-build", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + DynamicLib = "dylib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + StaticLib = "staticlib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + CDynamicLib = "cdylib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + RustLib = "rlib", +} + +export namespace CargoTargetKind { + export function isLibraryLike(targetKind:CargoTargetKind) { + return [ + CargoTargetKind.Lib, + CargoTargetKind.DynamicLib, + CargoTargetKind.StaticLib, + CargoTargetKind.CDynamicLib, + CargoTargetKind.RustLib, + ].includes(targetKind); + } +} + +export enum CargoCrateType { + Library = "lib", + Binary = "bin", +} + +/** This is only few part of the whole structure */ +export interface CargoPackageMetadata { + id: string; + name: string; + manifest_path: string; + targets: CargoTargetMetadata[]; +} + +export interface CargoTargetMetadata { + kind: CargoTargetKind[]; + name: string; + crate_types: CargoCrateType[]; + src_path: string; +} +``` + ## Hover Range **Upstream Issue:** https://github.com/microsoft/language-server-protocol/issues/377 diff --git a/docs/user/generated_config.adoc b/docs/user/generated_config.adoc index 71feed0f72ca..24e54cdf7686 100644 --- a/docs/user/generated_config.adoc +++ b/docs/user/generated_config.adoc @@ -847,6 +847,11 @@ Show full signature of the callable. Only shows parameters if disabled. -- Show documentation. -- +[[rust-analyzer.testExplorer.isEnabled]]rust-analyzer.testExplorer.isEnabled (default: `true`):: ++ +-- +Enable VSCode native test explorer. +-- [[rust-analyzer.typing.autoClosingAngleBrackets.enable]]rust-analyzer.typing.autoClosingAngleBrackets.enable (default: `false`):: + -- diff --git a/editors/code/package.json b/editors/code/package.json index 233e7bf44b16..45a8142800af 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -1560,6 +1560,11 @@ "default": true, "type": "boolean" }, + "rust-analyzer.testExplorer.enable": { + "markdownDescription": "Enable VSCode native test explorer.", + "default": true, + "type": "boolean" + }, "rust-analyzer.typing.autoClosingAngleBrackets.enable": { "markdownDescription": "Whether to insert closing angle brackets when typing an opening angle bracket of a generic argument list.", "default": false, diff --git a/editors/code/src/config.ts b/editors/code/src/config.ts index 9821aee6f92b..f7179e379794 100644 --- a/editors/code/src/config.ts +++ b/editors/code/src/config.ts @@ -200,6 +200,10 @@ export class Config { return prepareVSCodeConfig(this.cfg.get(path)); } + get isTestExplorerEnabled() { + return this.get("testExplorer.enable"); + } + get serverPath() { return this.get("server.path") ?? this.get("serverPath"); } diff --git a/editors/code/src/ctx.ts b/editors/code/src/ctx.ts index 904efa4d5eb0..c7e47e1e43cc 100644 --- a/editors/code/src/ctx.ts +++ b/editors/code/src/ctx.ts @@ -156,6 +156,7 @@ export class Ctx { this.traceOutputChannel = new LazyOutputChannel("Rust Analyzer Language Server Trace"); this.pushExtCleanup(this.traceOutputChannel); } + if (!this.outputChannel) { this.outputChannel = vscode.window.createOutputChannel("Rust Analyzer Language Server"); this.pushExtCleanup(this.outputChannel); diff --git a/editors/code/src/debug.ts b/editors/code/src/debug.ts index e817d680eaef..f7a37417740d 100644 --- a/editors/code/src/debug.ts +++ b/editors/code/src/debug.ts @@ -20,7 +20,7 @@ export async function makeDebugConfig(ctx: Ctx, runnable: ra.Runnable): Promise< const scope = ctx.activeRustEditor?.document.uri; if (!scope) return; - const debugConfig = await getDebugConfiguration(ctx, runnable); + const debugConfig = await getDebugConfigurationByRunnable(ctx, runnable); if (!debugConfig) return; const wsLaunchSection = vscode.workspace.getConfiguration("launch", scope); @@ -43,9 +43,9 @@ export async function makeDebugConfig(ctx: Ctx, runnable: ra.Runnable): Promise< await wsLaunchSection.update("configurations", configurations); } -export async function startDebugSession(ctx: Ctx, runnable: ra.Runnable): Promise { +export async function getDebugConfiguration(ctx: Ctx, runnable: ra.Runnable) { let debugConfig: vscode.DebugConfiguration | undefined = undefined; - let message = ""; + let isFromLacunchJson = false; const wsLaunchSection = vscode.workspace.getConfiguration("launch"); const configurations = wsLaunchSection.get("configurations") || []; @@ -53,15 +53,20 @@ export async function startDebugSession(ctx: Ctx, runnable: ra.Runnable): Promis const index = configurations.findIndex((c) => c.name === runnable.label); if (-1 !== index) { debugConfig = configurations[index]; - message = " (from launch.json)"; + isFromLacunchJson = true; debugOutput.clear(); } else { - debugConfig = await getDebugConfiguration(ctx, runnable); + debugConfig = await getDebugConfigurationByRunnable(ctx, runnable); } + return { isFromLacunchJson, debugConfig }; +} + +export async function startDebugSession(ctx: Ctx, runnable: ra.Runnable): Promise { + const { debugConfig, isFromLacunchJson } = await getDebugConfiguration(ctx, runnable); if (!debugConfig) return false; - debugOutput.appendLine(`Launching debug configuration${message}:`); + debugOutput.appendLine(`Launching debug configuration${isFromLacunchJson ? " (from launch.json)" : ""}:`); debugOutput.appendLine(JSON.stringify(debugConfig, null, 2)); return vscode.debug.startDebugging(undefined, debugConfig); } @@ -72,12 +77,10 @@ function createCommandLink(extensionId: string): string { return `extension.open?${encodeURIComponent(`"${extensionId}"`)}`; } -async function getDebugConfiguration( +async function getDebugConfigurationByRunnable( ctx: Ctx, runnable: ra.Runnable, ): Promise { - const editor = ctx.activeRustEditor; - if (!editor) return; const knownEngines: Record = { "vadimcn.vscode-lldb": getLldbDebugConfig, @@ -119,7 +122,7 @@ async function getDebugConfiguration( !isMultiFolderWorkspace || !runnable.args.workspaceRoot ? firstWorkspace : workspaceFolders.find((w) => runnable.args.workspaceRoot?.includes(w.uri.fsPath)) || - firstWorkspace; + firstWorkspace; const workspace = unwrapUndefinable(maybeWorkspace); const wsFolder = path.normalize(workspace.uri.fsPath); @@ -209,5 +212,6 @@ function getCppvsDebugConfig( cwd: runnable.args.workspaceRoot, sourceFileMap, env, + }; } diff --git a/editors/code/src/lsp_ext.ts b/editors/code/src/lsp_ext.ts index bb7896973f17..678fea1fc21e 100644 --- a/editors/code/src/lsp_ext.ts +++ b/editors/code/src/lsp_ext.ts @@ -3,6 +3,7 @@ */ import * as lc from "vscode-languageclient"; +import { CargoMetadata } from "./toolchain"; // rust-analyzer overrides @@ -68,6 +69,13 @@ export const viewItemTree = new lc.RequestType "rust-analyzer/viewItemTree", ); +export const cargoWorkspaces = new lc.RequestType0( + "rust-analyzer/cargoWorkspaces" +); + +export const testRunnablesInFile = new lc.RequestType( + "rust-analyzer/testRunnablesInFile" +); export type AnalyzerStatusParams = { textDocument?: lc.TextDocumentIdentifier }; export interface FetchDependencyListParams {} @@ -111,6 +119,7 @@ export type ExpandedMacro = { expansion: string; }; export type TestInfo = { runnable: Runnable }; + export type SyntaxTreeParams = { textDocument: lc.TextDocumentIdentifier; range: lc.Range | null; @@ -190,6 +199,10 @@ export type RunnablesParams = { textDocument: lc.TextDocumentIdentifier; position: lc.Position | null; }; +export type TestRunnablesInFileParams = { + textDocument: lc.TextDocumentIdentifier; +}; + export type ServerStatusParams = { health: "ok" | "warning" | "error"; quiescent: boolean; diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts index ee5e5b1b80c8..2fe2279db332 100644 --- a/editors/code/src/main.ts +++ b/editors/code/src/main.ts @@ -6,15 +6,26 @@ import { type CommandFactory, Ctx, fetchWorkspace } from "./ctx"; import * as diagnostics from "./diagnostics"; import { activateTaskProvider } from "./tasks"; import { setContextValue } from "./util"; +import { activeTestController, deactivateTestController } from "./test_explorer"; const RUST_PROJECT_CONTEXT_NAME = "inRustProject"; +/** + * Think carefully before using this directly. + * + * In most cases the work is finished by commands, and the context will pass itself + * as parameter to the command callback, which is defined when registering. + */ +export let raContext: Ctx | undefined; + export interface RustAnalyzerExtensionApi { readonly client?: lc.LanguageClient; } export async function deactivate() { await setContextValue(RUST_PROJECT_CONTEXT_NAME, undefined); + deactivateTestController(); + raContext = undefined; } export async function activate( @@ -28,19 +39,22 @@ export async function activate( "both plugins to not work correctly. You should disable one of them.", "Got it", ) - .then(() => {}, console.error); + .then(() => { }, console.error); } - const ctx = new Ctx(context, createCommands(), fetchWorkspace()); + raContext = new Ctx(context, createCommands(), fetchWorkspace()); // VS Code doesn't show a notification when an extension fails to activate // so we do it ourselves. - const api = await activateServer(ctx).catch((err) => { + const api = await activateServer(raContext).catch((err) => { void vscode.window.showErrorMessage( `Cannot activate rust-analyzer extension: ${err.message}`, ); throw err; }); await setContextValue(RUST_PROJECT_CONTEXT_NAME, true); + if (raContext.config.isTestExplorerEnabled) { + activeTestController(); + } return api; } @@ -144,7 +158,7 @@ function createCommands(): Record { health: "stopped", }); }, - disabled: (_) => async () => {}, + disabled: (_) => async () => { }, }, analyzerStatus: { enabled: commands.analyzerStatus }, diff --git a/editors/code/src/tasks.ts b/editors/code/src/tasks.ts index 1d5ab82aa04b..dd91f344a9e3 100644 --- a/editors/code/src/tasks.ts +++ b/editors/code/src/tasks.ts @@ -115,7 +115,7 @@ export async function buildCargoTask( if (!exec) { // Check whether we must use a user-defined substitute for cargo. // Split on spaces to allow overrides like "wrapper cargo". - const overrideCargo = definition.overrideCargo ?? definition.overrideCargo; + const overrideCargo = definition.overrideCargo; const cargoPath = await toolchain.cargoPath(); const cargoCommand = overrideCargo?.split(" ") ?? [cargoPath]; diff --git a/editors/code/src/test_explorer/README.md b/editors/code/src/test_explorer/README.md new file mode 100644 index 000000000000..e833ca073a2e --- /dev/null +++ b/editors/code/src/test_explorer/README.md @@ -0,0 +1,232 @@ +## How it works + +### Glossary +Runnable: Rust Analyzer has an internal structure called "Runnable", which is used to debug/run, which you might already be familar with. +TestItem: This is the structure used by vscode, and it's the surface of VSCode and RA. +TestModelNodes: This is a very easy AST, help to store meta info of tests and structure. + +### Basic +Bascially, we maintain TestModel tree and build test items based on TestModel tree and runnables. + + + +## Issues +There are many strategies about when to send what requests. + +Like the laziness is a big choice. When would you like to load how many tests? + +An obvious choice to to load all tests for all projects at the first time, then update the changed files. + +Another choice is only to load test cases laziness. Only when we open a file or click expand button of the case in test explorer, we load itself and its parents if they are not loaded yet. (this is what's used now, but this might introduce more bugs! Please submit an issue if you met it.) + +1. Where should user go when they click "open file" for test module, definition or the declaration? + +For now, I choose declaration +``` rs +//// mod.rs +mod foo; // <-- user will be redirect to here + +//// foo.rs +// some code(first line) // rather than here +// some code +``` + +Because most people know F12(goto implementation), and less people know "locate parent module" command. + +2. How to know whether a test case start? When run the whole test suite, how to know the test case in it is queued or started? + +Because the output is only text(some other framework might provide a server), we could only analytics the output. However, this is unstable and buggy in nature. And we could not always get what we want. In the worst case, we could only guess. + +For example +``` +--- Workspace +| //omit cargo file +|-package1 +| | // omit cargo file +| |-tests +| |-foo-bar.rs +| +| +|-package2 +| | // omit cargo file +| |-tests +| |-foo-bar.rs +``` +This is valid, however, the output will be somthing like +``` + Running tests/foo-bar.rs (target/debug/deps/foo_bar-b2e07b357bb81962) + +running 1 test +test foo1 ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + + Running tests/foo-bar.rs (target/debug/deps/foo_bar-ce4c61ef5dd225ce) + +running 1 test +test foo2 ... ok + +test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s + +``` +We could not distinguilish which target is executed exactly. The best thing we could do is to guess by the test path(in this example, they are "foo1" and "foo2") + +But the guess logic is not implemented yet :P. Instead, we not allow to run test on workspace level. + +3. For cargo, there is no way to match test mod exactly, let's say you have tests +``` rs +mod1::mod2::mod3::test1 + +mod2::mod3::test2 + +mod2::mod3::test3 +``` + +Then, you want to test all cases under mod2::mod3, but sadly, test1 will be matched too. This will rarely happens in a real repo, but it should be a flaw. + +And you could even declare such situation +``` rs +mod1::foo(this is a test module) + +mod1::foo(this is a test case) +``` + +When you want to run `mod1::foo`(module), the cause will be matched too. + +- Maybe we could add "::" at the end if it's a test module + +4. Altough in the design, the `path` attribute is considered, it will make things much more complex, let skip it for the first PR. + +5. How to make sure ra is updated before the request? + +6. The error message shown on the test rather than the line. + - enhance the analyze + +7. User could only choose one test case to run + - Maybe filter could help + - But it seems we could never run differnt target + +8. As mentioned in 2 point, "run all" does not work for workspace level for now. + +9. Debug will not update the state of test item.(could provide better experience for Linux) + + +### mermaid graph + +Use https://mermaid.live/ or VSCode plugin to show the flow + +Init + +``` mermaid +sequenceDiagram + participant U as User + participant VSC as VSCode + participant C as VSCode extension + participant RA as Rust Analyzer + + U->>+VSC: Open testing explorer + VSC->>+C: resolver sends init request + C->>+RA: get cargo project info + RA->>-C: back cargo project info + C->>C: construct Ideal tree about workspace and package part + loop until all targets are got + C->>+RA: request test infos for target root files (repeat many times) + RA->>-C: request test infos for target root files + end + C->>C: construct Ideal tree about target root files part + C->>C: construct TestItem tree By Ideal tree + C->>-VSC: VSCode got the Test Item tree + VSC->>-U: Render Test Explorer +``` + +Change avtive file +``` mermaid +sequenceDiagram + participant U as User + participant VSC as VSCode + participant C as VSCode extension + participant RA as Rust Analyzer + + U->>+VSC: Change active document + VSC->>+C: trigger event + C->>C: check whether the file is already loaded + alt is already loaded + C->>VSC: nothing changed + VSC->>U: nothing changed + else file is not loaed + Note over C: Load the file, but we might need to load its parent + loop until the module is loaded + C->>C: find nearest parent of the file module in ideal tree + C->>RA: get module info of the file + RA->>C: return module info of the file + C->>C: add new nodes to ideal tree + end + C->>C: construct TestItem tree By Ideal tree + C->>-VSC: VSCode got the Test Item tree + VSC->>-U: Render Test Explorer + end +``` + +Add rust file +Change rust file +Delete rust file +``` mermaid +sequenceDiagram + participant U as User + participant VSC as VSCode + participant C as VSCode extension + participant RA as Rust Analyzer + + U->>+VSC: Add/Delete/Change file + VSC->>+C: trigger event + C->>RA: request test info in file + RA->>C: back test info in file + C->>C: update ideal tree + Note over C: This is different for different operations + C->>C: construct TestItem tree By Ideal tree + C->>-VSC: VSCode got the Test Item tree + VSC->>-U: Render Test Explorer +``` + + +Debug +``` mermaid +sequenceDiagram + participant U as User + participant VSC as VSCode + participant C as VSCode extension + participant LLDB as LLDB extension + + U->>+VSC: Click run/debug button + VSC->>+C: testing API trigger event + C->>C: compute VSCode Debug configuration(LLDB) + C->>C: Set test status for test items + C->>VSCode: Start debug + VSCode->>LLDB: Debugger protocal + LLDB->>VSCode: Debugger protocal + VSCode->>C: debug session + C->>C: Analytics test output from rustc and update status of test items + C->>C: Attach info to test items(if any) + C->>-VSC: return + VSC->>-U: return +``` + +Run +``` mermaid +sequenceDiagram + participant U as User + participant VSC as VSCode + participant C as VSCode extension + participant Cargo as Cargo + + U->>+VSC: Click run/debug button + VSC->>+C: testing API trigger event + C->>C: compute Cargo command + C->>C: Set test status for test items + C->>Cargo: execute commands + Cargo->>C: run tests + C->>C: Analytics test output from rustc and update status of test items + C->>C: Attach info to test items(if any) + C->>-VSC: return + VSC->>-U: return +``` diff --git a/editors/code/src/test_explorer/RunnableFacde.ts b/editors/code/src/test_explorer/RunnableFacde.ts new file mode 100644 index 000000000000..7eabb193b12f --- /dev/null +++ b/editors/code/src/test_explorer/RunnableFacde.ts @@ -0,0 +1,223 @@ +import * as vscode from "vscode"; +import type * as ra from "../lsp_ext"; +import type * as lc from "vscode-languageclient"; +import { assert, assertNever } from "../util"; +import { TargetKind, NodeKind, type TestLikeNodeKind, type TestLocation } from "./test_model_tree"; + +/** + * A wrapper of `ra.Runnable` to provide typed/cached information rather than string. + * + * An important asumption is the format of `label` is + * - if test, "test test::path". Because test always has name, so the later part could not be empty. + * - if test module, "test-mod test::path". Attention, the later part could be empty if it's the root module of a target. + */ +export class RunnableFacde { + public readonly origin: ra.Runnable; + + constructor(runnable: ra.Runnable) { + this.origin = runnable; + } + + public toTestLocation(): TestLocation { + return { + uri: this.uri, + range: new vscode.Range( + this.origin.location!.targetSelectionRange.start.line, + this.origin.location!.targetSelectionRange.start.character, + this.origin.location!.targetSelectionRange.end.line, + this.origin.location!.targetSelectionRange.end.character, + ) + }; + } + + private _workspaceRoot?: string; + + get workspaceRoot(): string { + if (this._workspaceRoot) return this._workspaceRoot; + + const workspaceRoot = this.origin.args.workspaceRoot; + + assert(!!workspaceRoot); + + return this._workspaceRoot = workspaceRoot; + } + + private _testKind?: TestLikeNodeKind; + + get testKind(): TestLikeNodeKind { + if (this._testKind) return this._testKind; + const testKindString = this.origin.label.split(' ')[0]; + + switch (testKindString) { + case 'test': + return this._testKind = NodeKind.Test; + case 'test-mod': + return this._testKind = NodeKind.TestModule; + default: + throw new Error("What could it be?"); + } + } + + get targetName(): string { + switch (this.targetKind) { + case TargetKind.Binary: + assert(!!this.binaryTestFileName); + return this.binaryTestFileName; + case TargetKind.IntegrationTest: + assert(!!this.integrationTestFileName); + return this.integrationTestFileName; + case TargetKind.Library: + return this.packageName; + default: + assertNever(this.targetKind); + } + } + + get testPaths(): string[] { + const testModulePath = this.origin.label.split(' ')[1]!; + return testModulePath.split('::'); + } + + get testOrSuiteName(): string { + const candidateName = this.testPaths[this.testPaths.length - 1]; + // It should be safe, + // - if it's a test, this is its name + // - if it's a test module, this is the name of module + return candidateName!; + } + + private _targetKind?: TargetKind; + + get targetKind(): TargetKind { + if (this._targetKind) return this._targetKind; + + switch (true) { + case this.origin.args.cargoArgs.includes("--lib"): + return this._targetKind = TargetKind.Library; + case this.origin.args.cargoArgs.includes("--test"): + return this._targetKind = TargetKind.IntegrationTest; + case this.origin.args.cargoArgs.includes("--bin"): + return this._targetKind = TargetKind.Binary; + default: + throw new Error("Packge shold not be target level"); + } + } + + private _packageName?: string; + + get packageName(): string { + if (this._packageName) return this._packageName; + + const packageQualifiedNameIndex = this.origin.args.cargoArgs.findIndex(arg => arg === "--package") + 1; + + // The format of `packageQualifiedName` is `name:version`, like `hello:1.2.3` + const packageQualifiedName = this.origin.args.cargoArgs[packageQualifiedNameIndex]; + + assert(!!packageQualifiedName, "There should be a value for '--package' in runnable"); + + return this._packageName = packageQualifiedName.split(':')[0]!; + } + + private _integrationTestFileName?: string | null; + + /** + * Only have value if `targetKind` is `TargetKind.IntegrationTest` + */ + get integrationTestFileName(): string | null { + + if (this._integrationTestFileName !== undefined) return this._integrationTestFileName; + + const integrationTestFileNameIndex = this.origin.args.cargoArgs.findIndex(arg => arg === "--test") + 1; + + if (integrationTestFileNameIndex === 0) { + this._integrationTestFileName = null; + } else { + this._integrationTestFileName = this.origin.args.cargoArgs[integrationTestFileNameIndex]; + assert(typeof this._integrationTestFileName === "string","There should be a value for '--test' in runnable"); + } + + return this._integrationTestFileName; + } + + private _binaryTestFileName?: string | null; + + /** + * Only have value if `targetKind` is `TargetKind.Binary` + */ + get binaryTestFileName(): string | null { + if (this._binaryTestFileName !== undefined) return this._binaryTestFileName; + + const integrationTestFileNameIndex = this.origin.args.cargoArgs.findIndex(arg => arg === "--bin") + 1; + + if (integrationTestFileNameIndex === 0) { + this._binaryTestFileName = null; + } else { + this._binaryTestFileName = this.origin.args.cargoArgs[integrationTestFileNameIndex]; + assert(typeof this._binaryTestFileName === "string","There should be a value for '--bin' in runnable"); + } + + return this._binaryTestFileName; + } + + private _uri?: vscode.Uri; + + get uri(): vscode.Uri { + if (this._uri) return this._uri; + + assert(!!this.origin.location?.targetUri, "Need to investigate why targetUri is undefined"); + + return this._uri = vscode.Uri.parse(this.origin.location.targetUri); + } + + static sortByLabel(a: RunnableFacde, b: RunnableFacde): number { + return a.origin.label.localeCompare(b.origin.label); + } + + /** + * Whether the runnable is a declaration module like "mod xxx;" + */ + get isTestModuleDeclarationRunnable() { + assert(this.testKind === NodeKind.TestModule, "Only compare definition for test module."); + + return !this.isTestModuleFileDefinitionRunnable + // filter out module with items + // Not accurate. But who will write `mode xxx { ... }` in one line? + && this.origin.location?.targetRange.end.line === this.origin.location?.targetSelectionRange.end.line; + } + + /** + * whether the runnable is a definition module like "mod xxx { ... }" + */ + get isTestModuleWithItemsRunnable() { + assert(this.testKind === NodeKind.TestModule, "Only compare definition for test module."); + + return !this.isTestModuleFileDefinitionRunnable + && !this.isTestModuleDeclarationRunnable; + } + + /** + * Whether the runnable is a file definition module. + */ + get isTestModuleFileDefinitionRunnable() { + const runnable = this.origin; + + assert(this.testKind === NodeKind.TestModule, "Only compare definition for test module."); + + assert(!!runnable.location, "Should always have location"); + + return isRangeValueEqual( + runnable.location.targetRange, + runnable.location.targetSelectionRange, + ); + + function isRangeValueEqual(a: lc.Range, b: lc.Range) { + return isPositiionValueEqual(a.start, b.start) + && isPositiionValueEqual(a.end, b.end); + } + + function isPositiionValueEqual(a: lc.Position, b: lc.Position) { + return a.line === b.line + && a.character === b.character; + } + } +} diff --git a/editors/code/src/test_explorer/RustcOutputAnalyzer.ts b/editors/code/src/test_explorer/RustcOutputAnalyzer.ts new file mode 100644 index 000000000000..698a5ae3189e --- /dev/null +++ b/editors/code/src/test_explorer/RustcOutputAnalyzer.ts @@ -0,0 +1,330 @@ +import * as vscode from "vscode"; +import { assert, assertNever } from "../util"; +import { getTestItemByTestLikeNode, getTestModelByTestItem } from "./discover_and_update"; +import { + type CargoPackageNode, + DummyRootNode, + NodeKind, + type TargetNode, + type TestModuleNode, + type TestNode, + getPackageNodeOfTestModelNode, +} from "./test_model_tree"; +import { sep } from 'node:path'; + +/** + * When running tests, rust would run built target one by one and output something like: + * + * "Running unittests src\lib.rs (target\debug\deps\regex-6eb576da3e025f5d.exe)" + */ +class SuiteContext { + private static sepInRegexString = sep === '\\' ? '\\\\' : sep; + + private static relativePathCaptureGroupName = 'relativePath'; + + private static normalizedTargetCaptureGroupName = 'normalizedTargetName'; + /** + * Match the relative path of the target file, and the normalized target name + * + * @example "Running unittests src\\lib.rs (target\\debug\\deps\\hashbrown-3547e1bc587fc63a.exe)" + * // when target is lin/bin in Windows, the output is as above + * // we want to get "src\\lib.rs" and "hashbrown" + */ + private static targetPattern = new RegExp(`Running (?:unittests )?(?<${SuiteContext.relativePathCaptureGroupName}>.*?) \(.*${SuiteContext.sepInRegexString}(?<${SuiteContext.normalizedTargetCaptureGroupName}>.*?)-.*?\)`); + + /** + * .e.g, 'src/lib.rs', seprator is os-sensitive + */ + targetRelativePath: string; + + /** + * normarlized target name, '-' is relaced by '_' + * + * please refer https://www.reddit.com/r/rust/comments/8sezkm/where_are_the_rules_for_creating_valid_rust + */ + normalizedTargetName: string; + + private constructor(relativePath: string, normalizedTargetName: string) { + this.normalizedTargetName = normalizedTargetName; + this.targetRelativePath = relativePath; + } + + public static tryParse(line:string) { + const match = this.targetPattern.exec(line); + + if (!match) { + return undefined; + } + + const targetRelativePath = match.groups?.[SuiteContext.relativePathCaptureGroupName]!; + const normalizedTargetName = match.groups?.[SuiteContext.normalizedTargetCaptureGroupName]!; + + return new SuiteContext( + targetRelativePath, + normalizedTargetName, + ); + } +} + +// why replace: refer https://code.visualstudio.com/api/extension-guides/testing#test-output +function normalizeOutputDataForVSCodeTestOutput(data: any): string { + return data.toString().replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); +} + +class TestItemLocator { + private readonly _testModel: CargoPackageNode | TargetNode | TestModuleNode | TestNode; + + // We only allow one test case to be runned + constructor(chosenRunnedTestItem: vscode.TestItem) { + const node = getTestModelByTestItem(chosenRunnedTestItem); + + assert(node.kind === NodeKind.Test + || node.kind === NodeKind.TestModule + || node.kind === NodeKind.Target + || node.kind === NodeKind.CargoPackage, + "does not support workspace level, until we allow try to guess the target" + ); + + this._testModel = node; + } + + /** + * @param path This is the path which is shown on the output of test result, like mod1::mod2::mod3::test1 + */ + findTestItemByRustcOutputCasePath(suiteContext: SuiteContext, path: string): vscode.TestItem | undefined { + const { + normalizedTargetName, + targetRelativePath, + } = suiteContext; + // const workspaceRootNode = getWorkspaceNodeOfTestModelNode(this._testModel); + let targetNode = tryGetTargetNodeOfTestModelNode(this._testModel); + + if (!targetNode) { + const packageNode = getPackageNodeOfTestModelNode(this._testModel); + + const targetCandidates = + // workspaceRootNode.members + // .flatMap(packageNode => Array.from(packageNode.targets)) + Array.from(packageNode.targets) + .filter(target => + normalizeTargetName(target.name) === normalizedTargetName + && target.srcPath.fsPath.includes(targetRelativePath) + ); + + assert(targetCandidates.length === 1, "should find one and only one target node, but they might have same name and relative path, although it should be really rare"); + // REVIEW: What should we do if we found 2 or more candidates? + targetNode = targetCandidates[0]!; // safe, we have checked the length + } + + const testNode = DummyRootNode.instance.findTestLikeNodeUnderTarget( + targetNode, + NodeKind.Test, + path.split('::') + ); + + const candidate = getTestItemByTestLikeNode(testNode); + + return candidate; + + function tryGetTargetNodeOfTestModelNode(testModel: TestModuleNode | TargetNode | TestNode | CargoPackageNode) { + if (testModel.kind === NodeKind.CargoPackage) return undefined; + while (testModel.kind !== NodeKind.Target) { + testModel = testModel.parent; + } + return testModel; + } + + } +} + +function normalizeTargetName(packageName: string) { + return packageName.replace(/-/g, '_'); +} + +/** + * This analyzer assumes `--show-output` option is enabled for rustc.(which is unstable yet, so `-Z unstable-options` must also be enabled) + */ +class JsonFormatRustcOutputAnalyzer { + private _isTestStarted = false; + + private _testItemLocator: TestItemLocator; + + private _suiteContext: SuiteContext | undefined; + + protected _testRun: vscode.TestRun; + + constructor( + testRun: vscode.TestRun, + testItem: vscode.TestItem, + ) { + this._testRun = testRun; + this._testItemLocator = new TestItemLocator(testItem); + } + + protected handleNewLine(line: string) { + const isJsonOutput = line.startsWith('{'); + + if (!isJsonOutput) { + this._testRun.appendOutput(line + '\r\n'); // must be 'CRLF', refer https://code.visualstudio.com/api/extension-guides/testing#test-output + const newSuiteContextCandidate = SuiteContext.tryParse(line); + if (newSuiteContextCandidate) { + this._suiteContext = newSuiteContextCandidate; + } + } else { + const event = TestEvents.parse(line); + this.analyticsEvent(event); + } + } + + private analyticsEvent(testEvent: TestEvents) { + switch (testEvent.type) { + case 'test': + this.analyticsTestEvent(testEvent); + break; + case 'suite': + this.analyticsSuiteEvent(testEvent); + break; + default: + assertNever(testEvent); + } + } + + private analyticsTestEvent(testEvent: RustcTestCaseEvent) { + assert(this._isTestStarted); + assert(!!this._suiteContext, "Test must belong to a suite"); + const testItem = this._testItemLocator.findTestItemByRustcOutputCasePath(this._suiteContext, testEvent.name); + + assert(!!testItem); + + switch (testEvent.event) { + case 'started': + this._testRun.started(testItem); + break; + case 'failed': + this._testRun.failed(testItem, testEvent.stdout ? [new vscode.TestMessage(testEvent.stdout)] : [], fromSecondsToMilliseconds(testEvent.exec_time)); + break; + case 'ignored': + this._testRun.skipped(testItem); + break; + case 'ok': + this._testRun.passed(testItem, fromSecondsToMilliseconds(testEvent.exec_time)); + break; + default: + break; + } + } + + private analyticsSuiteEvent(testEvent: RustcSuiteEvent) { + switch (testEvent.event) { + case 'started': + assert(this._isTestStarted === false); + this._isTestStarted = true; + break; + case 'failed': + assert(this._isTestStarted === true); + this._isTestStarted = false; + break; + case 'ok': + assert(this._isTestStarted === true); + this._isTestStarted = false; + break; + default: + assertNever(testEvent); + } + } +} + +export class StreamJsonFormatRustcOutputAnalyzer extends JsonFormatRustcOutputAnalyzer { + constructor( + testRun: vscode.TestRun, + testItem: vscode.TestItem, + ) { + super(testRun,testItem); + } + + public onStdErr(data: any) { + // Some messages in the output are logged as stderr + // like + // "Finished test [unoptimized + debuginfo] target(s) in 0.07s" + // "Running unittests src\lib.rs (target\debug\deps\hashbrown-3547e1bc587fc63a.exe)" + this.handleStreamData(data); + } + + public onClose() { + this._testRun.end(); + } + + public onStdOut(data: any) { + this.handleStreamData(data); + } + + private handleStreamData(data: any) { + // It seems like the data will be end with a line breaking. Is this promised? + const normalizedData = normalizeOutputDataForVSCodeTestOutput(data); + + const lines = normalizedData.split("\r\n"); + + lines.forEach(line => { + this.handleNewLine(line); + }); + } +} + +export class LinesJonsFormatRustOutputAnalyzer extends JsonFormatRustcOutputAnalyzer { + constructor( + testRun: vscode.TestRun, + testItem: vscode.TestItem, + ) { + super(testRun,testItem); + } + + public analyticsLines(lines:string[]) { + lines.forEach(line => { + this.handleNewLine(line); + }); + this._testRun.end(); + } +} + +type RustcSuiteEvent = RustcSuiteStartEvent | RustcSuiteEndEvent; +type TestEvents = RustcTestCaseEvent | RustcSuiteEvent; + +namespace TestEvents { + export function parse(str: string): TestEvents { + const result = JSON.parse(str); + assert(result.type === 'test' || result.type === 'suite'); + return result as TestEvents; + } +} + +interface RustcTestCaseEvent { + type: "test"; + event: "started"|"ignored"| "failed" | "ok"; + name: string; + stdout?: string; + exec_time?: number; +} + +interface RustcSuiteStartEvent{ + type: "suite"; + event: "started"; +} + +interface RustcSuiteEndEvent{ + type: "suite"; + event: "failed" | "ok"; + name: string; + passed: number; + failed: number; + ignored: number; + measured: number; + filtered_out: number; + exec_time?: number; +} + +/** + * The time from rustc is seconds, but vscode accepts milliseconds + */ +function fromSecondsToMilliseconds(seconds: number | undefined) { + return seconds === undefined ? undefined : seconds * 1000; +} diff --git a/editors/code/src/test_explorer/TestItemControllerHelper.ts b/editors/code/src/test_explorer/TestItemControllerHelper.ts new file mode 100644 index 000000000000..cd00fe4e5f3c --- /dev/null +++ b/editors/code/src/test_explorer/TestItemControllerHelper.ts @@ -0,0 +1,28 @@ +import type * as vscode from "vscode"; +import { testController } from "."; + +/** + * General helper functions for VSCode TestItemController + */ +export abstract class TestItemControllerHelper { + /** + * + * @param cb Stop serach the current subtree if returning non-falsy value. But the rest subtree will continute to search. + * @param root + */ + static visitTestItemTreePreOrder( + cb: (item: vscode.TestItem, collection: vscode.TestItemCollection) => any, + root: vscode.TestItemCollection = testController!.items, + exitCb?: (item: vscode.TestItem, collection: vscode.TestItemCollection) => any + ) { + root.forEach((item, collection) => { + const res = cb(item, collection); + if (res) { + exitCb?.(item, collection); + return; + } + TestItemControllerHelper.visitTestItemTreePreOrder(cb, item.children, exitCb); + exitCb?.(item, collection); + }); + } +} diff --git a/editors/code/src/test_explorer/api_helper.ts b/editors/code/src/test_explorer/api_helper.ts new file mode 100644 index 000000000000..ec124755c176 --- /dev/null +++ b/editors/code/src/test_explorer/api_helper.ts @@ -0,0 +1,74 @@ +import type * as vscode from 'vscode'; +import { raContext } from '../main'; +import * as ra from "../lsp_ext"; +import * as lc from "vscode-languageclient"; +import { assert } from 'console'; + +/** + * A simplified definition request. + * + * Should only be used to get the definition of module declaration. + * + * And therefore, there will only be one location. + */ +const moduleDefinitionRequest = new lc.RequestType('textDocument/definition'); + +export abstract class RaApiHelper { + static async getTestRunnablesInFile(uri: vscode.Uri) { + const client = raContext?.client; + if (!client) { + return null; + } + + const testInfos = await client.sendRequest(ra.testRunnablesInFile, { + textDocument: lc.TextDocumentIdentifier.create(uri.toString()), + }); + + return testInfos; + } + + static async parentModue(uri: vscode.Uri): Promise { + const client = raContext?.client; + if (!client) { + return null; + } + const documentUriString = uri.toString(); + assert(lc.DocumentUri.is(documentUriString)); + + const locations = await client.sendRequest(ra.parentModule, { + textDocument: lc.TextDocumentIdentifier.create(documentUriString), + position: lc.Position.create(0, 0), + }); + return locations; + } + + static async moduleDefinition(locationLink: lc.LocationLink): Promise { + const client = raContext?.client; + if (!client) { + return null; + } + const position = locationLink.targetSelectionRange.start; + + assert(lc.DocumentUri.is(locationLink.targetUri)); + + const location = await client.sendRequest(moduleDefinitionRequest, { + textDocument: lc.TextDocumentIdentifier.create(locationLink.targetUri), + position: lc.Position.create(position.line, position.character), + }); + return location; + } + + /** + * + * @returns cargo workspaces with depdencies. One RA instance could support multi different workspaces. + */ + static async cargoWorkspaces() { + const client = raContext?.client; + if (!client) { + return null; + } + const cargoWorkspaces = await client.sendRequest(ra.cargoWorkspaces); + + return cargoWorkspaces; + } +} diff --git a/editors/code/src/test_explorer/discover_and_update.ts b/editors/code/src/test_explorer/discover_and_update.ts new file mode 100644 index 000000000000..29f5fa33552f --- /dev/null +++ b/editors/code/src/test_explorer/discover_and_update.ts @@ -0,0 +1,846 @@ +import * as vscode from "vscode"; +import { testController } from "."; +import type * as ra from "../lsp_ext"; +import { assert, assertNever, isCargoTomlDocument, isRustDocument, sleep } from "../util"; +import { RaApiHelper } from "./api_helper"; +import { RunnableFacde } from "./RunnableFacde"; +import type { CargoMetadata } from "../toolchain"; +import { + type CargoPackageNode, + type CargoWorkspaceNode, + TargetNode, + NodeKind, + TestModuleNode, + isTestModuleNode, + WorkspacesWalker, + TestNode, + type Nodes, + TargetKind, + type TestLikeNode, + isTestNode, + isTestLikeNode, + DummyRootNode, + UriMatcher, +} from "./test_model_tree"; +import { fail } from "assert"; + +export const disposiables: vscode.Disposable[] = []; + +async function discoverAllFilesInWorkspaces() { + if (!vscode.workspace.workspaceFolders) { + return; + } + + await refreshCore(); +} + +function registerWatcherForWorkspaces() { + if (!vscode.workspace.workspaceFolders) { + return; + } + + // listen to document changes to re-parse unsaved changes: + const disposable = vscode.workspace.onDidChangeTextDocument(async e => { + const document = e.document; + + if (isRustDocument(document)) { + await handleRustFileChange(document.uri); + return; + } + + if (isCargoTomlDocument(document)) { + await handleRustProjectFileEvent(e.document.uri); + return; + } + }); + disposiables.push(disposable); + + vscode.workspace.workspaceFolders + .map(watchWorkspace); +} + +function registerActiveTextEditor() { + const disposable = vscode.window.onDidChangeActiveTextEditor(onDidChangeActiveTextEditorForTestExplorer); + disposiables.push(disposable); +} + +/** + * whether the file is already loaded in test model tree + */ +function isRustFileAlreadyLoaded(uri: vscode.Uri) { + const nodes = UriMatcher.match(uri, DummyRootNode.instance); + return !!(nodes[0] && nodes[0].testChildren.size > 0); +} + +async function onDidChangeActiveTextEditorForTestExplorer(e: vscode.TextEditor | undefined) { + if (!testController) return; + + if (!e) { + return; + } + + if (isRustDocument(e.document)) { + const isDocumentLoaded = isRustFileAlreadyLoaded(e.document.uri); + if (isDocumentLoaded) { + return; + } + + // if the file is still not loaded yet + // as if the file is changed, to update its related info, immediately. + await handleRustFileChangeCore(e.document.uri); + return; + } +}; + +// Not watch the change of file(the disk), instead, use `onDidChangeTextDocument` to watch the editor(the memory of VSCode) +// +// This also means, please do not use other ways to change the file, such as in terminal or another editor +// However, VSCode would trigger `onDidChangeTextDocument` for an opened file when you change and saved it in other place +// +// Because: +// 1. if auto-save is enabled, the event would be triggered twice, then we need to give a longer debounce time(more than 1s) to avoid duplicate work +// 2. For now, RA is synced with VSCode rather than disk +// 2.2 A change in disk is confused in fact. Let's say you have content A on dist, content B on VSCode. You save content C on disk now. +// What should we do? What should VSCode do? Should VSCode send content C to RA thourgh LSP? If so, it would be inconsistant with the content in VSCode! +function watchWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + const rsRrojectWatcher = watchRustProjectFileChange(workspaceFolder); + const rsFileWatcher = watchRustFileChange(workspaceFolder); + disposiables.push(rsRrojectWatcher); + disposiables.push(rsFileWatcher); + + // For now, the only supported project file is cargo. + function watchRustProjectFileChange(workspaceFolder: vscode.WorkspaceFolder): vscode.FileSystemWatcher { + const pattern = new vscode.RelativePattern(workspaceFolder, '**/Cargo.toml'); + const watcher = vscode.workspace.createFileSystemWatcher( + pattern, + false, + true, // not listen to change event in fact + false + ); + watcher.onDidCreate(handleRustProjectFileEvent); + watcher.onDidDelete(handleRustProjectFileEvent); + return watcher; + } + + function watchRustFileChange(workspaceFolder: vscode.WorkspaceFolder) { + const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.rs'); + const watcher = vscode.workspace.createFileSystemWatcher( + pattern, + false, + true, // not listen to change event in fact + false + ); + watcher.onDidCreate(handleRustFileCreate); + watcher.onDidDelete(handleRustFileDelete); + return watcher; + } +} + +// refresh all things if the project file is added/changed/deleted +// Because we do not know whther the change would +// - change packages +// - change targets(.e.g, changing bin file path) +function handleRustProjectFileEvent(uri: vscode.Uri) { + // We need to order this after language server updates, but there's no API for that. + // debounce will wait a short time + debounceRefreshCore(); +} + +async function handleRustFileCreate(uri: vscode.Uri) { + // We need to order this after language server updates, but there's no API for that. + // Hence, good old sleep(). + await sleep(20); + await loadFileAndUpdateModel(uri); + updateTestItemsByModel(); +} + +async function handleRustFileChange(uri: vscode.Uri) { + // We need to order this after language server updates, but there's no API for that. + // debounce will wait a short time + debounceHandleRustFileChangeCore(uri); +} + +async function handleRustFileDelete(uri: vscode.Uri) { + // We need to order this after language server updates, but there's no API for that. + // Hence, good old sleep(). + await sleep(20); + DummyRootNode.instance.removeTestItemsRecursivelyByUri(uri); + updateTestItemsByModel(); +} + +const FILE_DEBOUNCE_DELAY_MS = 500; // 0.5s, assume charactor typing speed is 2/s + +// FIXME: if there are changes in two files, we will lost the first change. But it would rarely happen +function debounce(fn: Function, ms: number) { + let timeout: NodeJS.Timeout | undefined = undefined; + return (...params: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + fn(...params); + }, ms); + }; +} + +// FIXME: if there are changes in two files, we will lost the first chagne +const debounceHandleRustFileChangeCore = debounce(handleRustFileChangeCore, FILE_DEBOUNCE_DELAY_MS); +const debounceRefreshCore = debounce(refreshCore, FILE_DEBOUNCE_DELAY_MS); + +export async function refreshHandler() { + await refreshCore(); +} + +async function refreshCore() { + if (!testController) return; + + // Discard all + // should we discard the old model tree here? should the user see the previous test items when refreshing? + // testController.items.replace([]); + DummyRootNode.instance.clear(); + + const cargoMetadataArray = await RaApiHelper.cargoWorkspaces(); + + if (!cargoMetadataArray) return; + + // The workspaces got from RA contains depdency packages(.i.e, RA does not add "--no-deps" when running `cargo metadata`) + // But they are not needed in test explorer + const noDepsWorkspaces = cargoMetadataArray.map(filterOutDepdencyPackages); + + DummyRootNode.instance.initByMedatada(noDepsWorkspaces); + + // After init, the target might not conatins any test(rather than not-fetched tests) + // So we could not collect nodes which children need to be fetched, and fetch them + // Instead, we pretend the behavior they are changed, so that the empty target will be removed + + const allTargetUris = noDepsWorkspaces.flatMap(it => + it.packages + .flatMap(p => p.targets) + .map(target => target.src_path) + .map(vscode.Uri.file) + ); + + for (const uri of allTargetUris) { + await loadFileAndUpdateModel(uri); + } + + // update all test info in current file, and trigger build of test item tree + await onDidChangeActiveTextEditorForTestExplorer(vscode.window.activeTextEditor); + + function filterOutDepdencyPackages(metadata: CargoMetadata) { + return { + ...metadata, + packages: metadata.packages.filter(p => + metadata.workspace_members.includes(p.id) + ) + }; + } +} + +async function handleRustFileChangeCore(uri: vscode.Uri) { + await loadFileAndUpdateModel(uri); + updateTestItemsByModel(); +} + +export const resolveHandler = async function (item:vscode.TestItem|undefined) { + if (!item) { + // init logic + await discoverAllFilesInWorkspaces(); + + registerWatcherForWorkspaces(); + registerActiveTextEditor(); + return; + } + + assert(!!item.uri, "Always give a uri to a test item"); + + + const node = getTestModelByTestItem(item); + + switch (node.kind) { + case NodeKind.DummyRoot: + fail("Dummy root should never be visited"); + case NodeKind.CargoWorkspace: + fail("Package data is got when getting workspace data, no need to be resolved lazily"); + case NodeKind.CargoPackage: + fail("Targets data is got when getting workspace data, no need to be resolved lazily"); + case NodeKind.Target: + fail("The children for target are handled specially. Target is the surface of cargo metadata and front-end life-cycle for now. Eagerly fetch the children to verify whether there is tests or not."); + case NodeKind.TestModule: + if (node.testChildren.size > 0) { + return; + } + item.busy = true; + await fetchAndUpdateChildrenForTestModuleNode(node); + item.busy = false; + break; + case NodeKind.Test: + fail("test does not contain any children, and should not be be able to resolve."); + } + + // WorkspacesPrinter.print(node); + + // add the new test items to existing test item tree + VscodeTestTreeBuilder.buildChildrenFor(node); +}; + +// Rebuild the whole test item tree +function updateTestItemsByModel() { + assert(!!testController); + testController.items.replace([]); + // WorkspacesPrinter.print(DummyRootNode.instance); + const rootTestItems = VscodeTestTreeBuilder.build(); + testController.items.replace(rootTestItems); +} + +async function getNormalizedTestRunnablesInFile(uri: vscode.Uri) { + const rawRunables = await RaApiHelper.getTestRunnablesInFile(uri); + + assert(!!rawRunables); + + const runnables = rawRunables.map(it => new RunnableFacde(it)); + + // User might copy and paste test, and then there might be same name test or test module + // Although it's wrong, we need to tolerate it. + // pick the first one. + return uniqueRunnables(runnables); + + function uniqueRunnables(runnables: RunnableFacde[]) { + const map = new Map(); + runnables.forEach(runnable => { + const key = `${runnable.workspaceRoot}|${runnable.packageName}|${runnable.targetKind}|${runnable.targetName}|${runnable.origin.label}`; + if (!map.has(key)) { + map.set(key, runnable); + } + }); + return Array.from(map.values()); + } +} + +async function loadFileAndUpdateModel(uri: vscode.Uri) { + const runnables = await getNormalizedTestRunnablesInFile(uri); + + // Maybe from some to none + // need to recursively clean the parent, until there is at least one test cases. + if (runnables.length === 0) { + DummyRootNode.instance.removeTestItemsRecursivelyByUri(uri); + return; + } + + const testModuelRunnables = runnables.filter(it => + it.testKind === NodeKind.TestModule) + .sort(RunnableFacde.sortByLabel); + + const testItemRunnables = runnables.filter(it => + it.testKind === NodeKind.Test); + + assert(testModuelRunnables.length + testItemRunnables.length === runnables.length); + + // FIXME: should be file test modules, because of `path` attribute + const fileTestModuleRunnbale = testModuelRunnables[0]!; + + const nearestNode = DummyRootNode.instance.findNearestNodeByRunnable(fileTestModuleRunnbale); + + assert(nearestNode.kind !== NodeKind.Test, "it's a test module"); + assert(nearestNode.kind !== NodeKind.CargoWorkspace, "We never delete workspace and package info unless refresh, so at least it's a package"); + + // create target node when creating the first test for some target. + // This is necessary, because we do not know how many targets a package contains unless we fetch data thorugh `cargo metadata` + // But we want to only fetch it when cargo file is changed, to make things more lazily. + if (fileTestModuleRunnbale.origin.label === "test-mod " + && nearestNode.kind !== NodeKind.TestModule) { + assert(nearestNode.kind === NodeKind.CargoPackage, "we do not delete package node unless refetch metadata"); + // This runnable is from a target, create the target if it's not exist in test model tree + const newTargetNode = new TargetNode(nearestNode, + fileTestModuleRunnbale.targetKind, + fileTestModuleRunnbale.targetName, + fileTestModuleRunnbale.uri.fsPath); + nearestNode.targets.add(newTargetNode); + } + + await ensureTestModuleParentExist(fileTestModuleRunnbale); + + const parentModule = DummyRootNode.instance.findNearestNodeByRunnable(fileTestModuleRunnbale); + + assert(isTestModuleNode(parentModule)); + + await updateFileDefinitionTestModuleByRunnables(parentModule, runnables); + + async function ensureTestModuleParentExist(runnable: RunnableFacde) { + let nearestNode = DummyRootNode.instance.findNearestNodeByRunnable(fileTestModuleRunnbale); + + assert(isTestLikeNode(nearestNode)); + + while (!isTestNodeAndRunnableMatched(nearestNode, runnable)) { + // parent test node is not existed, create it recursively + assert(isTestModuleNode(nearestNode)); + await fetchAndUpdateChildrenForTestModuleNode(nearestNode); + + nearestNode = DummyRootNode.instance.findNearestNodeByRunnable(fileTestModuleRunnbale); + assert(isTestLikeNode(nearestNode)); + } + } +} + +async function fetchAndUpdateChildrenForTestModuleNode(testModuleNode: TestModuleNode) { + assert( + testModuleNode.isDummyTestModule() === + (testModuleNode.declarationInfo.uri.toString() === testModuleNode.definitionUri.toString()) + , "The test module is either a declaration module, or the root module of some target node"); + + const definitionUri = testModuleNode.definitionUri; + + const runnables = await getNormalizedTestRunnablesInFile(definitionUri); + + await updateFileDefinitionTestModuleByRunnables(testModuleNode, runnables); +} + +function categorizeRunnables(runnables: RunnableFacde[]) { + const testModuelRunnables = runnables.filter(it => + it.testKind === NodeKind.TestModule); + + const testRunnables = runnables.filter(it => + it.testKind === NodeKind.Test); + + assert(testModuelRunnables.length + testRunnables.length === runnables.length); + + const declarationModuleRunnables = testModuelRunnables.filter(r => r.isTestModuleDeclarationRunnable); + const fileDefinitionModuleRunnables = testModuelRunnables.filter(r => r.isTestModuleFileDefinitionRunnable); + const withItemsModuleRunnables = testModuelRunnables.filter(r => r.isTestModuleWithItemsRunnable); + + assert(declarationModuleRunnables.length + fileDefinitionModuleRunnables.length + withItemsModuleRunnables.length === testModuelRunnables.length); + return { + testRunnables, + declarationModuleRunnables, + fileDefinitionModuleRunnables, + withItemsModuleRunnables + }; +} + +/** + * If node and runnable is matched, the only possible differnce should be location + * In the other word, the test item build by the test model could be run through this runnable + */ +function isTestNodeAndRunnableMatched(node: TestLikeNode, runnable: RunnableFacde): node is TestLikeNode { + return runnable.testPaths.join() === node.testPaths.join(); +} + +/** + * Update test module node's children with new fetched runnables + * + * It's assumed the runnables are fetched from the same file of the test module node + * + * @param parentNode + * @param runnables + */ +async function updateFileDefinitionTestModuleByRunnables(parentNode: TestModuleNode, runnables: RunnableFacde[]) { + const { added, deleted, updated } = distinguishChanges(runnables); + + /// updated + updated.forEach(([testLikeNode, runnable]) => { + // update the relationship + // although it should be fine to not update, because we only use runnable later to run/debug + // and use the old runnable does not influce the args + runnableByTestModel.set(testLikeNode, runnable); + // update the location + updateLocationOfTestLikeByRunnable(testLikeNode, runnable); + }); + + /// deleted + deleted.forEach(node => { + assert(node.parent.kind === NodeKind.TestModule); + node.parent.testChildren.delete(node); + }); + + /// added + const { declarationModuleRunnables, fileDefinitionModuleRunnables, testRunnables, withItemsModuleRunnables, } = categorizeRunnables(added); + + // Handle fileDefinitionModules + // Not Handle fileDefinitionModule, we choose to use definition rather then declaration as the presentation of test module + // which means, when user goto the test, will be rediredct to the declaration rather than some file. + + // Handle testRunnables and test modules which have items, which are in the same test file + addTestModuleWithItemsRunnablesToTestModule(parentNode, withItemsModuleRunnables); + addTestModuleWithItemsRunnablesToTestModule(parentNode, testRunnables); + + // Handle declarationModules + // TODO: maybe concurrent? + for (const declarationModuleRunnable of declarationModuleRunnables) { + await addDeclarationModuleRunnableToTestModule(parentNode, declarationModuleRunnable); + } + + function distinguishChanges(runnables: RunnableFacde[]) { + const { declarationModuleRunnables, fileDefinitionModuleRunnables, testRunnables, withItemsModuleRunnables } = categorizeRunnables(runnables); + + const finalRunnables = [...declarationModuleRunnables, ...testRunnables, ...withItemsModuleRunnables]; + + // get previous children of the test module node + const childrenOfParentnode = ChildrenCollector.collect(parentNode); + const childrenInTheSameFile = childrenOfParentnode.filter(node => isTestLikeNodeInTheSameFile(node, parentNode)); + + // distinguish update/add/delete nodes + const updated: [TestLikeNode, RunnableFacde][] = []; + const added: RunnableFacde[] = []; + const deleted: Set = new Set(childrenInTheSameFile); + + finalRunnables.forEach(it => { + const testNode = DummyRootNode.instance.findNearestNodeByRunnable(it); + assert(isTestLikeNode(testNode)); + if (isTestNodeAndRunnableMatched(testNode, it)) { + updated.push([testNode, it]); + deleted.delete(testNode); + } else { + added.push(it); + } + }); + + return { + updated, + added, + deleted, + }; + } + + function isTestLikeNodeInTheSameFile(a: TestLikeNode, b: TestModuleNode) { + switch (a.kind) { + case NodeKind.TestModule: + return a.declarationInfo.uri.toString() === b.definitionUri.toString(); + case NodeKind.Test: + return a.location.uri.toString() === b.definitionUri.toString(); + } + } + + function updateLocationOfTestLikeByRunnable(node: TestLikeNode, runnable: RunnableFacde) { + switch (node.kind) { + case NodeKind.TestModule: + node.declarationInfo = runnable.toTestLocation(); + break; + case NodeKind.Test: + node.location = runnable.toTestLocation(); + } + } + + async function addDeclarationModuleRunnableToTestModule(parentNode: TestModuleNode, declarationModuleRunnable: RunnableFacde) { + const definition = await getModuleDefinitionLocation(declarationModuleRunnable); + + // Add declarationModule node into the tree + const testModule = new TestModuleNode( + parentNode, + declarationModuleRunnable.testOrSuiteName, + declarationModuleRunnable.toTestLocation(), + vscode.Uri.parse(definition.targetUri)); + runnableByTestModel.set(testModule, declarationModuleRunnable); + parentNode.testChildren.add(testModule); + } + + // This function will add the descendants of a test module into it + function addTestModuleWithItemsRunnablesToTestModule(parentNode: TestModuleNode, runnables: RunnableFacde[]) { + // sort to ensure the parent is added before the chidren + runnables.sort(RunnableFacde.sortByLabel) + .forEach(runnable => { + const parentNode = DummyRootNode.instance.findNearestNodeByRunnable(runnable); + assert(parentNode.kind === NodeKind.TestModule, "Runable should be inserted into TestModule/Test, we create mock runnable for target/workspace node"); + if (!parentNode.isDummyTestModule()) { + assert(parentNode.name === runnable.testPaths[runnable.testPaths.length - 2]); + } + + switch (runnable.testKind) { + case NodeKind.Test: + const testNode = new TestNode(parentNode, + runnable.toTestLocation(), + runnable.testOrSuiteName); + runnableByTestModel.set(testNode, runnable); + parentNode.testChildren.add(testNode); + break; + case NodeKind.TestModule: + const testModuleNode = new TestModuleNode( + parentNode, + runnable.testOrSuiteName, + runnable.toTestLocation(), + runnable.uri, + ); + runnableByTestModel.set(testModuleNode, runnable); + parentNode.testChildren.add(testModuleNode); + break; + default: + assertNever(runnable.testKind); + } + }); + } +} + +// Get all children of a test-like node +class ChildrenCollector extends WorkspacesWalker { + private constructor(private rootNode: TestLikeNode) { + super(); + } + + public static collect(node: TestLikeNode) { + const it = new ChildrenCollector(node); + it.apply(node); + return Array.from(it.result); + } + + private result: Set = new Set(); + + protected override visitTestModuleNode(node: TestModuleNode): void { + if (!(node === this.rootNode)) { + this.result.add(node); + } + + super.visitTestModuleNode(node); + } + + protected override visitTestNode(node: TestNode): void { + this.result.add(node); + + super.visitTestNode(node); + } +} + +const testItemByTestLike = new Map(); +const testModelByTestItem = new WeakMap(); +const runnableByTestModel = new WeakMap(); + +export function getTestItemByTestLikeNode(testLikeNode: TestLikeNode): vscode.TestItem { + const testItem = testItemByTestLike.get(testLikeNode); + assert(!!testItem); + return testItem; +} + +export function getTestModelByTestItem(testItem: vscode.TestItem): Nodes { + const testModel = testModelByTestItem.get(testItem); + assert(!!testModel); + return testModel; +} + +function getRunnableByTestModel(testModel: Nodes): RunnableFacde { + let testLikeNode: TestLikeNode | undefined; + switch (testModel.kind) { + case NodeKind.DummyRoot: + fail("Never"); + case NodeKind.CargoWorkspace: + fail("Do not support for now"); + case NodeKind.CargoPackage: + return createMockPackageRootRunnable(testModel); + case NodeKind.Target: { + testLikeNode = testModel.dummyTestModule; + const runnable = runnableByTestModel.get(testLikeNode); + assert(!!runnable); + return runnable; + } + case NodeKind.TestModule: + case NodeKind.Test: + testLikeNode = testModel; + const runnable = runnableByTestModel.get(testLikeNode); + assert(!!runnable); + return runnable; + default: + assertNever(testModel); + } + + function createMockPackageRootRunnable(packageNode: CargoPackageNode) { + const packageMockRunnable: ra.Runnable = { + label: 'test-mod ', + kind: 'cargo', + location: { + targetUri: packageNode.manifestPath.fsPath, + targetRange: new vscode.Range(0, 0, 0, 0), + targetSelectionRange: new vscode.Range(0, 0, 0, 0), + }, + args: { + workspaceRoot: packageNode.parent.workspaceRoot.fsPath, + cargoArgs: [ + "test", + "--package", + packageNode.name, + "--lib", + "--bins", + "--tests", + ], + cargoExtraArgs: [], + executableArgs: [], + } + }; + + const packgeRunnable = new RunnableFacde(packageMockRunnable); + + return packgeRunnable; + } +} + +export function getRunnableByTestItem(testItem: vscode.TestItem): RunnableFacde { + const testModel = getTestModelByTestItem(testItem); + const runnable = getRunnableByTestModel(testModel); + return runnable; +} + +// Build vscode.TestItem tree +// and bind TestModel and vscode.TestItem +class VscodeTestTreeBuilder extends WorkspacesWalker { + private static singlton = new VscodeTestTreeBuilder(); + + public static buildChildrenFor(node: TestModuleNode) { + const { singlton } = VscodeTestTreeBuilder; + // not traversal the node itself + node.testChildren.forEach(child => { + singlton.apply(child); + }); + } + + public static build() { + const { singlton } = VscodeTestTreeBuilder; + testItemByTestLike.clear(); + singlton.rootTestItems = []; + singlton.testItemByNode.clear(); + singlton.apply(DummyRootNode.instance); + const result = singlton.rootTestItems; + return result; + } + + private rootTestItems: vscode.TestItem[] = []; + + private testItemByNode = new Map(); + + private addTestItemToParentOrRoot(node: Nodes, testItem: vscode.TestItem) { + testModelByTestItem.set(testItem, node); + + if (isTestModuleNode(node) || isTestNode(node)) { + testItemByTestLike.set(node, testItem); + } + + this.testItemByNode.set(node, testItem); + + const parentTestItem = tryGetParentTestItem.call(this, node); + + if (parentTestItem) { + parentTestItem.children.add(testItem); + } else { + this.rootTestItems.push(testItem); + } + + function tryGetParentTestItem(this: VscodeTestTreeBuilder, node: Nodes) { + let curNode = node; + while (curNode.parent) { + const candidate = this.testItemByNode.get(curNode.parent); + if (candidate) { + return candidate; + } + curNode = curNode.parent; + } + return undefined; + } + } + + // Need this, for we do not delete workace node unless refetch metadata. + private isWorkspaceEmptyWithTests(node: CargoWorkspaceNode) { + return node.members.every(this.isPackageEmptyWithTests); + } + + // Need this, we do not delete package node unless refetch metadata. + private isPackageEmptyWithTests(node: CargoPackageNode) { + return node.targets.size === 0; + } + + protected override visitCargoWorkspaceNode(node: CargoWorkspaceNode) { + // if there is only one workspace, do not create a test item node for it + // Flatten the items + if (DummyRootNode.instance.roots.length === 1) { + return super.visitCargoWorkspaceNode(node); + } + + // if there is no tests in workspace, not create test-item. + // and not traversal subtree + if (this.isWorkspaceEmptyWithTests(node)) { + return; + } + + const testItem = testController!.createTestItem(node.workspaceRoot.toString(), `$(project)${node.workspaceRoot.fsPath}`, node.manifestPath); + this.addTestItemToParentOrRoot(node, testItem); + + super.visitCargoWorkspaceNode(node); + } + + protected override visitCargoPackageNode(node: CargoPackageNode) { + // if there is only one package, do not create a test item node for it + // Flatten the items + if (node.parent.members.length === 1) { + return super.visitCargoPackageNode(node); + } + + // if there is no tests in workspace, not create test-item. + // and not traversal subtree + if (this.isPackageEmptyWithTests(node)) { + return; + } + + const testItem = testController!.createTestItem(node.manifestPath.fsPath, `$(package)${node.name}`, node.manifestPath); + this.addTestItemToParentOrRoot(node, testItem); + + super.visitCargoPackageNode(node); + } + + protected override visitTargetNode(node: TargetNode) { + // if there is only one target, do not create a test item node for it + // Flatten the items + if (node.parent.targets.size === 1) { + return super.visitTargetNode(node); + } + + let icon: string; + switch (node.targetKind) { + case TargetKind.Binary: + icon = "$(run)"; + break; + case TargetKind.Library: + icon = "$(library)"; + break; + case TargetKind.IntegrationTest: + icon = "$(beaker)"; + break; + default: + assertNever(node.targetKind); + } + + const testItem = testController!.createTestItem(`${icon}${node.name}`, `${icon}${node.name}`, node.srcPath); + this.addTestItemToParentOrRoot(node, testItem); + + super.visitTargetNode(node); + } + + protected override visitTestModuleNode(node: TestModuleNode) { + if (node.isDummyTestModule()) { + // not create test item for root test module, which is representated by corresponding target node. + return super.visitTestModuleNode(node); + } + + const testItem = testController!.createTestItem(node.name, `$(symbol-module)${node.name}`, node.declarationInfo.uri); + testItem.range = node.declarationInfo.range; + const isChildrenFetched = node.testChildren.size !== 0; + const isDeclarationModule = node.declarationInfo.uri.toString() !== node.definitionUri.toString(); + + if (!isChildrenFetched && isDeclarationModule) { + testItem.canResolveChildren = true; + } + + this.addTestItemToParentOrRoot(node, testItem); + + super.visitTestModuleNode(node); + } + + protected override visitTestNode(node: TestNode) { + const testItem = testController!.createTestItem(node.name, `$(symbol-method)${node.name}`, node.location.uri); + testItem.range = node.location.range; + this.addTestItemToParentOrRoot(node, testItem); + + super.visitTestNode(node); + } +} + +async function getModuleDefinitionLocation(runnable: RunnableFacde) { + assert(runnable.isTestModuleDeclarationRunnable); + + const definitionLocations = await RaApiHelper.moduleDefinition(runnable.origin.location!); + + assert(definitionLocations?.length === 1, "There should always be one and only one module definition for any module declaration."); + + return definitionLocations[0]!; // safe, for we have checked the length +} diff --git a/editors/code/src/test_explorer/index.ts b/editors/code/src/test_explorer/index.ts new file mode 100644 index 000000000000..c4af134d25d2 --- /dev/null +++ b/editors/code/src/test_explorer/index.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; +import { refreshHandler, resolveHandler, disposiables } from "./discover_and_update"; +import { runHandler } from "./run_or_debug"; + +export let testController: vscode.TestController | undefined; + +export function deactivateTestController(): void { + testController?.dispose(); + while (disposiables.length !== 0) { + const watcher = disposiables.pop(); + watcher?.dispose(); + } + testController = undefined; +} + +export function activeTestController(): void { + testController?.dispose(); + + testController = vscode.tests.createTestController( + 'rust-analyzer-test-controller', + 'Rust Tests' + ); + + testController.createRunProfile( + 'Run', + vscode.TestRunProfileKind.Run, + runHandler, + true, + ); + + testController.createRunProfile( + 'Debug', + vscode.TestRunProfileKind.Debug, + runHandler, + true, + ); + + testController.resolveHandler = async (item) => { + await resolveTaskQueue.queue(() => resolveHandler(item)); + }; + + testController.refreshHandler = refreshHandler; +} + +class TaskQueue { + private lastTask: Promise = Promise.resolve(); + + public async queue(task: () => Promise) { + await this.lastTask; + this.lastTask = task(); + } +} + +const resolveTaskQueue = new TaskQueue(); diff --git a/editors/code/src/test_explorer/run_or_debug.ts b/editors/code/src/test_explorer/run_or_debug.ts new file mode 100644 index 000000000000..c1399ae7487e --- /dev/null +++ b/editors/code/src/test_explorer/run_or_debug.ts @@ -0,0 +1,256 @@ +/* eslint-disable no-console */ +import * as vscode from "vscode"; +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as toolchain from "../toolchain"; +import { testController } from "."; +import { spawn } from "child_process"; +import { assert } from "../util"; +import { createArgs, prepareEnv } from "../run"; +import { getRunnableByTestItem } from "./discover_and_update"; +import { TestItemControllerHelper } from "./TestItemControllerHelper"; +import { getDebugConfiguration } from "../debug"; +import { raContext } from "../main"; +import { StreamJsonFormatRustcOutputAnalyzer, LinesJonsFormatRustOutputAnalyzer } from "./RustcOutputAnalyzer"; +import { fail } from "assert"; +import { NodeKind } from "./test_model_tree"; + +export async function runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken +) { + // TODO: Never run tests concurrently in client side. + // TODO: could not run on workspace/package level, waiting for https://github.com/vadimcn/codelldb/issues/948 + + const chosenItems = await getChosenTestItems(request); + + if (!chosenItems) { + return; + } + + const testRun = testController!.createTestRun(request); + + switch (request.profile?.kind) { + case vscode.TestRunProfileKind.Debug: + await debugChosenTestItems(testRun, chosenItems, token); + return; + case vscode.TestRunProfileKind.Run: + await runChosenTestItems(testRun, chosenItems, token); + return; + case vscode.TestRunProfileKind.Coverage: + await vscode.window.showErrorMessage("Not support Coverage yet"); + break; + case undefined: + await vscode.window.showErrorMessage("Never run programily, which means, only run thorugh UI"); + break; + default: + fail("TS does not support type narrow well in switch, never run here"); + } +} + +// const workspacesRunnable: ra.Runnable = { +// label: 'test-mod ', +// kind: 'cargo', +// location: { +// targetUri: "never_used", +// targetRange: { start: { character: 0, line: 0 }, end: { character: 0, line: 0 } }, +// targetSelectionRange: { start: { character: 0, line: 0 }, end: { character: 0, line: 0 } }, +// }, +// args: { +// cargoExtraArgs: [], +// cargoArgs: [ +// "test", +// "--workspace", +// "--lib", +// "--bins", +// "--tests", +// ], +// executableArgs: [], +// } +// }; + +async function getChosenTestItems(request: vscode.TestRunRequest) { + if (request.include === undefined) { + await vscode.window.showWarningMessage("Sorry, for now, one and only one test item need to be picked when using Testing Explorer powered by Rust-Analyzer"); + return undefined;//workspaceRunnable; + } + + if (request.include.length === 0) { + await vscode.window.showWarningMessage("There is no tests to run"); + return; + } + + if (request.include.length !== 1) { + await vscode.window.showWarningMessage("Sorry, for now, one and only one test item need to be picked when using Testing Explorer powered by Rust-Analyzer"); + return; + } + // Not handle exclude for now, because we only support one test item to run anyway. + + return request.include; +} + +async function debugChosenTestItems(testRun: vscode.TestRun, chosenTestItems: readonly vscode.TestItem[], token: vscode.CancellationToken) { + if (!raContext) { + return; + } + + // Without `await` intentionally, because we don't want to block the UI thread. + void vscode.window.showInformationMessage("The test item status will be updated after debug session is terminated"); + + assert(chosenTestItems.length === 1, "only support 1 select test item for debugging, at least for now."); + const chosenTestItem = chosenTestItems[0]!; // safe, because we have checked the length. + const runnable = getRunnableByTestItem(chosenTestItem); + const runnableOrigin = runnable.origin; + + const disposables: vscode.Disposable[] = []; + + // most of the following logic comes from vscode-java-test repo. Thanks! + const { debugConfig, isFromLacunchJson } = await getDebugConfiguration(raContext, runnableOrigin); + + if (!debugConfig) { + return; + } + + if (debugConfig.type !== 'lldb') { + await vscode.window.showInformationMessage("Sorry, for now, only [CodeLLDB](https://marketplace.visualstudio.com/items?itemName=vadimcn.vscode-lldb) is supported for debugging when using Testing Explorer powered by Rust-Analyzer" + + "You can use CodeLens to debug with [MS C++ tools](https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools)" + ); + return; + } + + let outputFilePath: string | undefined; + + if (isFromLacunchJson && debugConfig["stdio"]) { + // Without `await` intentionally, because we don't want to block the UI thread. + void vscode.window.showInformationMessage("The test choose config from launch.json and you alredy set Stdio Redirection option. We respect it but could not analytics the output."); + } else { + const tmpFolderPath = await fs.mkdtemp(path.join(os.tmpdir(), 'ra-test-redirect-')); + outputFilePath = path.join(tmpFolderPath, 'output.txt'); + debugConfig["stdio"] = [null, outputFilePath]; + } + + if (runnable.testKind === NodeKind.TestModule) { + TestItemControllerHelper.visitTestItemTreePreOrder(testItem => { + testRun.enqueued(testItem); + }, chosenTestItem.children); + } else { + testRun.enqueued(chosenTestItem); + } + + let debugSession: vscode.DebugSession | undefined; + disposables.push(vscode.debug.onDidStartDebugSession((session: vscode.DebugSession) => { + // Safe, because concurrently debugging is not allowed. + // So the name should not be duplicated + if (session.name === debugConfig.name) { + debugSession = session; + } + })); + + const success = await vscode.debug.startDebugging(undefined, debugConfig); + + if (!success || token.isCancellationRequested) { + dispose(); + return; + } + + token.onCancellationRequested(async () => { + await debugSession?.customRequest('disconnect', { restart: false }); + }); + + return await new Promise((resolve: () => void): void => { + disposables.push( + vscode.debug.onDidTerminateDebugSession(async (session: vscode.DebugSession) => { + if (debugConfig.name === session.name) { + debugSession = undefined; + if (outputFilePath) { + const fileLineContents = (await fs.readFile(outputFilePath, 'utf-8')) + .split(/\r?\n/); + const outputAnalyzer = new LinesJonsFormatRustOutputAnalyzer(testRun, chosenTestItem); + outputAnalyzer.analyticsLines(fileLineContents); + } + dispose(); + return resolve(); + } + }), + ); + }); + + function dispose() { + disposables.forEach(d => d.dispose()); + disposables.length = 0; + testRun.end(); + } +} + +// refer from playwright-vscode +/** + * @param chosenTestItems The chosen ones of test items. The test cases which should be run should be the children of them. + */ +async function runChosenTestItems(testRun: vscode.TestRun, chosenTestItems: readonly vscode.TestItem[], token: vscode.CancellationToken) { + assert(chosenTestItems.length === 1, "only support 1 select test item for running, at least for now."); + const chosenTestItem = chosenTestItems[0]!; // safe, because we have checked the length. + const runnable = getRunnableByTestItem(chosenTestItem); + const runnableOrigin = runnable.origin; + + const args = createArgs(runnableOrigin); + + const finalArgs = args + // Remove `--nocapture` + // so that we could analytics the output easily and always correctly. + // Otherwise, if the case writes into stdout, due to the parallel execution, + // the output might be messy and it might be even impossible to analytic. + .filter(arg => arg !== '--nocapture') + .concat( + // enable unstable features + '-Z', + 'unstable-options', + ) + // convert text output to events + // This makes the output much easier to be analyzed, + // and also enable us to know when which case is started(otherwise, we could only know when it is finished). + .concat('--format=json') // Not statble, need `-Z unstable-options` + // show exact time for test + .concat('--report-time',) // Not statble, need `-Z unstable-options` + // show output from succeed test + .concat('--show-output'); // statble + + const cwd = runnableOrigin.args.workspaceRoot || "."; + + assert(finalArgs[0] === 'test', "We only support 'cargo test' command in test explorer for now!"); + + // TODO: add override cargo + // overrideCargo: runnable.args.overrideCargo; + const cargoPath = await toolchain.cargoPath(); + + if (runnable.testKind === NodeKind.TestModule) { + TestItemControllerHelper.visitTestItemTreePreOrder(testItem => { + testRun.enqueued(testItem); + }, chosenTestItem.children); + } else { + testRun.enqueued(chosenTestItem); + } + + // output the runned command. + testRun.appendOutput(`${cargoPath} ${finalArgs.join(' ')}` + '\r\n'); + + const outputAnalyzer = new StreamJsonFormatRustcOutputAnalyzer(testRun, chosenTestItem); + + // start process and listen to the output + const childProcess = spawn(cargoPath, finalArgs, { + cwd, + stdio: ['pipe', 'pipe', 'pipe', 'pipe', 'pipe'], + // FIXME: Should we inheritage the runnableEnv too? + env: prepareEnv(runnableOrigin, /* config.runnableEnv */undefined), + }); + const stdio = childProcess.stdio; + stdio[1].on('data', data => outputAnalyzer.onStdOut(data)); + stdio[2].on('data', data => outputAnalyzer.onStdErr(data)); + childProcess.on('exit', () => outputAnalyzer.onClose()); + token.onCancellationRequested(() => { + if (!childProcess.killed) { + childProcess.kill(); + } + testRun.end(); + }); +} diff --git a/editors/code/src/test_explorer/test_model_tree.ts b/editors/code/src/test_explorer/test_model_tree.ts new file mode 100644 index 000000000000..6191e6613eaf --- /dev/null +++ b/editors/code/src/test_explorer/test_model_tree.ts @@ -0,0 +1,552 @@ +import * as path from "node:path"; +import * as vscode from 'vscode'; +import { type CargoMetadata, type CargoPackageMetadata, CargoTargetKind, type CargoTargetMetadata } from "../toolchain"; +import { assert, assertNever } from "../util"; +import type { RunnableFacde } from "./RunnableFacde"; +import { fail } from "node:assert"; + +export enum NodeKind { + // VSCodeWorkSpace, + DummyRoot, + CargoWorkspace, + CargoPackage, + Target, + TestModule, + Test, +} + +export enum TargetKind { + Library, + IntegrationTest, + Binary, +} + +export namespace TargetKind { + export function from(cargoTargetKinds: CargoTargetKind[]) { + if (cargoTargetKinds.length === 1) { + assert(!!cargoTargetKinds[0], "We have checked the length, just to narrow type for ts."); + switch (cargoTargetKinds[0]) { + case CargoTargetKind.Binary: + return TargetKind.Binary; + case CargoTargetKind.Lib: + case CargoTargetKind.RustLib: + case CargoTargetKind.CDynamicLib: + case CargoTargetKind.DynamicLib: + case CargoTargetKind.StaticLib: + return TargetKind.Library; + case CargoTargetKind.Test: + return TargetKind.IntegrationTest; + case CargoTargetKind.Example: + case CargoTargetKind.Bench: + case CargoTargetKind.BuildScript: + return undefined; + default: + assertNever(cargoTargetKinds[0]); + } + } else if (cargoTargetKinds.every(it => + CargoTargetKind.isLibraryLike(it))) { + return TargetKind.Library; + } else { + fail("Oops, you met an unknown situation that RA could not verify the kind of the target"); + } + } +} + +interface Node { + /** + * The parent of the node. + * + * `undefined` only if the node is root of the tree. + */ + readonly parent: Node | undefined; + readonly kind: NodeKind; +} + +export abstract class WorkspacesWalker { + protected constructor() { } + + protected apply(node: Nodes): void { + switch (node.kind) { + case NodeKind.DummyRoot: + DummyRootNode.instance.roots.forEach(workspaceNode => + this.visitCargoWorkspaceNode(workspaceNode)); + break; + case NodeKind.CargoWorkspace: + this.visitCargoWorkspaceNode(node); + break; + case NodeKind.CargoPackage: + this.visitCargoPackageNode(node); + break; + case NodeKind.Target: + this.visitTargetNode(node); + break; + case NodeKind.TestModule: + this.visitTestModuleNode(node); + break; + case NodeKind.Test: + this.visitTestNode(node); + break; + default: + assertNever(node); + } + } + + protected visitCargoWorkspaceNode(cargoWorkspaceNode: CargoWorkspaceNode):void { + cargoWorkspaceNode.members.forEach(packageNode => + this.visitCargoPackageNode(packageNode)); + } + + protected visitCargoPackageNode(cargoPackageNode: CargoPackageNode):void { + cargoPackageNode.targets.forEach(targetNode => + this.visitTargetNode(targetNode)); + } + + protected visitTargetNode(targetNode: TargetNode):void { + if (targetNode.dummyTestModule) { + this.visitTestModuleNode(targetNode.dummyTestModule); + } + } + + protected visitTestModuleNode(testModuleNode: TestModuleNode):void { + testModuleNode.testChildren.forEach(it => { + switch (it.kind) { + case NodeKind.TestModule: + this.visitTestModuleNode(it); + break; + case NodeKind.Test: + this.visitTestNode(it); + break; + default: + assertNever(it); + } + }); + } + + protected visitTestNode(testNode: TestNode):void { + } +} + +/** + * Dummy root node of the tree. + */ +export class DummyRootNode implements Node { + static readonly instance = new DummyRootNode(); + + readonly parent: undefined; + + readonly kind = NodeKind.DummyRoot; + + private constructor() { } + + readonly roots: CargoWorkspaceNode[] = []; + + clear() { + this.roots.splice(0, this.roots.length); + } + + // after init, there are target nodes(with its root test module), but there is no TestModule/Test + initByMedatada(metadata: CargoMetadata[]) { + metadata.forEach((m) => { + const cargoWorkspace = CargoWorkspaceNode.from(m); + this.roots.push(cargoWorkspace); + }); + } + + findNearestNodeByRunnable(runnable: RunnableFacde) { + const { + workspaceRoot, + packageName, + targetKind, + targetName, + testPaths, + testKind, + } = runnable; + + const workspaceNode = this.roots.find((root) => root.workspaceRoot.fsPath.toLowerCase() === workspaceRoot.toLowerCase()); + assert(!!workspaceNode); + + const packageNode = workspaceNode?.members.find((p) => p.name === packageName); + if (!packageNode) { + return workspaceNode; + } + + const targetNode = Array.from(packageNode.targets).find((t) => t.name === targetName && t.targetKind === targetKind); + if (!targetNode) { + return packageNode; + } + + assert(!!targetNode.dummyTestModule); + + return this.findTestLikeNodeUnderTarget(targetNode, testKind, testPaths); + } + + findTestLikeNodeUnderTarget(targetNode: TargetNode, testLevel: NodeKind.TestModule, testPaths: string[]): TestModuleNode; + findTestLikeNodeUnderTarget(targetNode: TargetNode, testLevel: NodeKind.Test, testPaths: string[]): TestNode; + findTestLikeNodeUnderTarget(targetNode: TargetNode, testLevel: TestLikeNodeKind, testPaths: string[]): TestLikeNode; + findTestLikeNodeUnderTarget(targetNode: TargetNode, testLevel: TestLikeNodeKind, testPaths: string[]): TestLikeNode { + let testModuleNode: TestModuleNode = targetNode.dummyTestModule; + + for (let index = 0; index < testPaths.length; index += 1) { + const testModuleNmae = testPaths[index]; + const targetKind = index === testPaths.length - 1 ? testLevel : NodeKind.TestModule; + + const candidate = Array.from(testModuleNode.testChildren).find((t) => + t.kind === targetKind && + t.name === testModuleNmae); + + if (!candidate) { + return testModuleNode; + } + + if (index === testPaths.length - 1) { + return candidate; + } + + assert(candidate.kind === NodeKind.TestModule); + testModuleNode = candidate; + } + + throw new Error("Should not reach here"); + } + + /** + * Remove the Target/TestModule/Test recusively, + * until there is at least one item after removed. + */ + removeTestItemsRecursivelyByUri(uri: vscode.Uri): void { + const nodes: TestLikeNode[] = UriMatcher.match(uri, DummyRootNode.instance); + nodes.forEach(removeRecursively); + } +} + +/** + * Print the whole tree, only for debug purpose. + */ +export class WorkspacesPrinter extends WorkspacesWalker { + private constructor() { + super(); + } + + public static print(node: Nodes) { + const printer = new WorkspacesPrinter(); + printer.apply(node); + } + + private _depth = 0; + + private callWithDepth(func: () => void) { + this._depth += 1; + func(); + this._depth -= 1; + } + + protected override visitCargoWorkspaceNode(cargoWorkspaceNode: CargoWorkspaceNode): void { + // eslint-disable-next-line no-console + console.log(Array(2*this._depth).join(' '), `Workspace: ${cargoWorkspaceNode.workspaceRoot}`); + + this.callWithDepth( + ()=> super.visitCargoWorkspaceNode(cargoWorkspaceNode) + ); + } + + protected override visitCargoPackageNode(cargoPackageNode: CargoPackageNode): void { + // eslint-disable-next-line no-console + console.log(Array(2*this._depth).join(' '), `Package: ${cargoPackageNode.manifestPath}`); + + this.callWithDepth( + ()=> super.visitCargoPackageNode(cargoPackageNode) + ); + } + + protected override visitTargetNode(targetNode: TargetNode): void { + // eslint-disable-next-line no-console + console.log(Array(2*this._depth).join(' '), `Target: ${targetNode.name} -- ${TargetKind[targetNode.targetKind]}`); + + this.callWithDepth( + ()=> super.visitTargetNode(targetNode) + ); + } + + protected override visitTestModuleNode(node: TestModuleNode) { + // eslint-disable-next-line no-console + console.log(Array(2 * this._depth).join(' '), `TestModule: ${node.name}`); + + this.callWithDepth( + ()=> super.visitTestModuleNode(node) + ); + } + + protected override visitTestNode(testNode: TestNode): void { + // eslint-disable-next-line no-console + console.log(Array(2*this._depth).join(' '), `Test: ${testNode.name}`); + + this.callWithDepth( + ()=> super.visitTestNode(testNode) + ); + } +} + +/** + * Find the the {@link TestModuleNode} in the given node by uri + */ +export class UriMatcher extends WorkspacesWalker { + private constructor(private currentUri: vscode.Uri | undefined) { + super(); + } + + public static match(uri: vscode.Uri, node: Nodes) { + const matcher = new UriMatcher(uri); + matcher.apply(node); + return Array.from(matcher.result); + } + + private result: Set = new Set(); + + protected override visitTestModuleNode(node: TestModuleNode) { + assert(!!this.currentUri); + + if (node.definitionUri.toString() === this.currentUri.toString()) { + this.result.add(node); + return; + } + + super.visitTestModuleNode(node); + } +} + +function removeRecursively(node: TestLikeNode) { + // delete the node from its parent, until + // - after removing, the parent still has at least one node, + // - Or the parent of node is package node + let curNode: RsNode | CargoPackageNode = node; + while (true) { + const parent: TestModuleNode | TargetNode | CargoPackageNode = curNode.parent; + switch (parent.kind) { + case NodeKind.CargoPackage: { + assert(curNode.kind === NodeKind.Target); + const isDeleted = parent.targets.delete(curNode); + assert(isDeleted, "node must be in the children of the parent"); + break; + } + case NodeKind.Target: + break; + case NodeKind.TestModule: { + assert( + curNode.kind === NodeKind.Test + || curNode.kind === NodeKind.TestModule + ); + const isDeleted = parent.testChildren.delete(curNode); + assert(isDeleted, "node must be in the children of the parent"); + break; + } + default: + assertNever(parent); + } + + curNode = parent; + + if (curNode.kind === NodeKind.CargoPackage) { + break; + } + + if (curNode.kind === NodeKind.TestModule && curNode.testChildren.size > 0) { + break; + } + } +} + +export class CargoWorkspaceNode implements Node { + readonly parent: DummyRootNode = DummyRootNode.instance; + readonly kind = NodeKind.CargoWorkspace; + readonly workspaceRoot: vscode.Uri; + readonly manifestPath: vscode.Uri; + readonly members: CargoPackageNode[] = []; + + static from(metadata: CargoMetadata): CargoWorkspaceNode { + const res = new CargoWorkspaceNode(metadata.workspace_root); + + assert(metadata.packages.length === metadata.workspace_members.length, "cargo medatada should only not contain depdencies"); + + metadata.packages.forEach((p) => { + const newPackageNode = CargoPackageNode.from(p, res); + res.members.push(newPackageNode); + }); + return res; + } + + private constructor(workspaceRoot: string) { + this.workspaceRoot = vscode.Uri.file(workspaceRoot); + this.manifestPath = vscode.Uri.file(path.join(workspaceRoot, 'Cargo.toml')); + } +} + +export class CargoPackageNode implements Node { + readonly parent: CargoWorkspaceNode; + readonly name: string; + readonly kind = NodeKind.CargoPackage; + // cargo path + readonly manifestPath: vscode.Uri; + readonly targets: Set = new Set(); + + static from(packageMetadata: CargoPackageMetadata, parent: CargoWorkspaceNode): CargoPackageNode { + const res = new CargoPackageNode(parent, packageMetadata.manifest_path, packageMetadata.name); + + packageMetadata.targets.forEach(target => { + const newTargetNode = TargetNode.from(target, res); + if (!newTargetNode) { + return; + } + + res.targets.add(newTargetNode); + }); + return res; + } + + private constructor(parent: CargoWorkspaceNode, manifestPath: string, name: string) { + this.parent = parent; + this.manifestPath = vscode.Uri.file(manifestPath); + this.name = name; + } +} + +export class TargetNode implements Node { + readonly parent: CargoPackageNode; + readonly kind = NodeKind.Target; + readonly name: string; + readonly srcPath: vscode.Uri; + readonly targetKind: TargetKind; + dummyTestModule: TestModuleNode; + + static from(targetMetadata: CargoTargetMetadata, parent: CargoPackageNode): TargetNode | undefined { + const targetKind = TargetKind.from(targetMetadata.kind); + if (targetKind === undefined) return undefined; + + const res = new TargetNode(parent, targetKind, targetMetadata.name, targetMetadata.src_path); + return res; + } + + constructor(parent: CargoPackageNode, targetKind: TargetKind, name: string, srcPath: string) { + this.parent = parent; + this.targetKind = targetKind; + this.name = name; + this.srcPath = vscode.Uri.file(srcPath); + this.dummyTestModule = new TestModuleNode( + this, + '', + { + uri: this.srcPath, + range: new vscode.Range(0, 0, 0, 0), + }, + this.srcPath); + } +} + +export type TestLikeNode = TestModuleNode | TestNode; +export type TestLikeNodeKind = NodeKind.TestModule | NodeKind.Test; + +/** + * Nodes which has a mapping rust file. + */ +type RsNode = TestLikeNode | TargetNode; + +export interface TestLocation { + uri: vscode.Uri; + range: vscode.Range; +} + +export class TestModuleNode implements Node { + /** + * Name of the test module + * + * A {@link TargetNode} contains a dummy test module, which has an empty name. + */ + readonly name: string; + readonly parent: TargetNode | TestModuleNode; + readonly kind = NodeKind.TestModule; + /// If test module is root of target node, range is all zero + // TODO: consider about `path`, this could be an array in fact + // But it requires to change the server code to fully support it. + declarationInfo: TestLocation; + readonly definitionUri: vscode.Uri; + readonly testChildren: Set = new Set(); + + get testPaths(): string[] { + if (this.isDummyTestModule()) { + return []; + } + + assert(this.parent.kind === NodeKind.TestModule); + + return [...this.parent.testPaths, this.name]; + } + + constructor(parent: TargetNode | TestModuleNode, name: string, declarationInfo: TestLocation, definitionUri: vscode.Uri) { + this.parent = parent; + this.declarationInfo = declarationInfo; + this.definitionUri = definitionUri; + this.name = name; + } + + public isDummyTestModule() { + return this.parent.kind === NodeKind.Target; + } +} + +export class TestNode implements Node { + readonly name: string; + readonly parent: TestModuleNode; + location: TestLocation; + readonly kind = NodeKind.Test; + + get testPaths(): string[] { + return [...this.parent.testPaths, this.name]; + } + + constructor(parent: TestModuleNode, location: TestLocation, name: string) { + this.parent = parent; + this.location = location; + this.name = name; + } +} + +export type Nodes = + | DummyRootNode + | CargoWorkspaceNode + | CargoPackageNode + | TargetNode + | TestModuleNode + | TestNode; + +export function isTragetNode(node: Nodes): node is TargetNode { + return node.kind === NodeKind.Target; +} + +export function isTestModuleNode(node: Nodes): node is TestModuleNode { + return node.kind === NodeKind.TestModule; +} + +export function isTestNode(node: Nodes): node is TestNode { + return node.kind === NodeKind.Test; +} + +export function isTestLikeNode(node: Nodes): node is TestLikeNode { + return isTestModuleNode(node) || isTestNode(node); +} + +export function getWorkspaceNodeOfTestModelNode(testModel: Nodes) { + assert(testModel.kind !== NodeKind.DummyRoot); + + while (testModel.kind !== NodeKind.CargoWorkspace) { + testModel = testModel.parent; + } + + return testModel; +} + +export function getPackageNodeOfTestModelNode(testModel: TestModuleNode | TargetNode | TestNode | CargoPackageNode) { + while (testModel.kind !== NodeKind.CargoPackage) { + testModel = testModel.parent; + } + + return testModel; +} diff --git a/editors/code/src/toolchain.ts b/editors/code/src/toolchain.ts index 58e5fc747a19..857db836dfd1 100644 --- a/editors/code/src/toolchain.ts +++ b/editors/code/src/toolchain.ts @@ -14,6 +14,74 @@ interface CompilationArtifact { isTest: boolean; } +/** + * The result of `cargo metadata` + * + * This is only part of the whole structure + */ +export interface CargoMetadata { + workspace_root: string; + workspace_members: string[]; + packages: CargoPackageMetadata[]; +} + +/** + * The value of property "cargo metadata".packages[x].targets[y].kind[0] + */ +export enum CargoTargetKind { + Lib = "lib", + Binary = "bin", + Test = "test", + Example = "example", + Bench = 'bench', + /** + * Refer "https://doc.rust-lang.org/cargo/reference/build-scripts.html" + * + * "build.rs" is a special target internally + */ + BuildScript = "custom-build", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + DynamicLib = "dylib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + StaticLib = "staticlib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + CDynamicLib = "cdylib", + /** refer https://doc.rust-lang.org/reference/linkage.html */ + RustLib = "rlib", +} + +export namespace CargoTargetKind { + export function isLibraryLike(targetKind:CargoTargetKind) { + return [ + CargoTargetKind.Lib, + CargoTargetKind.DynamicLib, + CargoTargetKind.StaticLib, + CargoTargetKind.CDynamicLib, + CargoTargetKind.RustLib, + ].includes(targetKind); + } +} + +export enum CargoCrateType { + Library = "lib", + Binary = "bin", +} + +/** This is only few part of the whole structure */ +export interface CargoPackageMetadata { + id: string; + name: string; + manifest_path: string; + targets: CargoTargetMetadata[]; +} + +export interface CargoTargetMetadata { + kind: CargoTargetKind[]; + name: string; + crate_types: CargoCrateType[]; + src_path: string; +} + export interface ArtifactSpec { cargoArgs: string[]; filter?: (artifacts: CompilationArtifact[]) => CompilationArtifact[]; diff --git a/editors/code/src/util.ts b/editors/code/src/util.ts index 51f921a29628..031940d25c9e 100644 --- a/editors/code/src/util.ts +++ b/editors/code/src/util.ts @@ -4,7 +4,9 @@ import { exec, type ExecOptions, spawnSync } from "child_process"; import { inspect } from "util"; import type { Env } from "./client"; -export function assert(condition: boolean, explanation: string): asserts condition { +export function noop() { } + +export function assert(condition: boolean, explanation?: string): asserts condition { try { nativeAssert(condition, explanation); } catch (err) { @@ -13,6 +15,10 @@ export function assert(condition: boolean, explanation: string): asserts conditi } } +export function assertNever(_value: never, reason?: string): never { + throw new Error(`AssertNever: ${reason}`); +} + export const log = new (class { private enabled = true; private readonly output = vscode.window.createOutputChannel("Rust Analyzer Client"); @@ -57,7 +63,7 @@ export const log = new (class { } })(); -export function sleep(ms: number) { +export async function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); }