diff --git a/libs/lsp-server-wrapper/src/jsonrpc.rs b/libs/lsp-server-wrapper/src/jsonrpc.rs index 664d6868..a7ead68f 100644 --- a/libs/lsp-server-wrapper/src/jsonrpc.rs +++ b/libs/lsp-server-wrapper/src/jsonrpc.rs @@ -1,4 +1,4 @@ mod error; pub(crate) use self::error::not_initialized_error; -pub use self::error::{Error, ErrorCode, Result}; +pub use self::error::{Error, Result}; diff --git a/toolchains/solidity/core/Cargo.lock b/toolchains/solidity/core/Cargo.lock index 868975a9..3fa2449a 100644 --- a/toolchains/solidity/core/Cargo.lock +++ b/toolchains/solidity/core/Cargo.lock @@ -76,19 +76,19 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" [[package]] name = "async-trait" -version = "0.1.74" +version = "0.1.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9" +checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -159,9 +159,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.4.11" +version = "4.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfaff671f6b22ca62406885ece523383b9b64022e341e53e009a62ebc47a45f2" +checksum = "52bdc885e4cacc7f7c9eedc1ef6da641603180c783c41a15c264944deeaab642" dependencies = [ "clap_builder", "clap_derive", @@ -169,9 +169,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.11" +version = "4.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a216b506622bb1d316cd51328dce24e07bdff4a6128a47c7e7fad11878d5adbb" +checksum = "fb7fb5e4e979aec3be7791562fcba452f94ad85e954da024396433e0e25a79e9" dependencies = [ "anstream", "anstyle", @@ -188,7 +188,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -215,9 +215,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.9" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c3242926edf34aec4ac3a77108ad4854bffaa2e4ddc1824124ce59231302d5" +checksum = "82a9b73a36529d9c47029b9fb3a6f0ea3cc916a261195352ba19e770fc1748b2" dependencies = [ "cfg-if", "crossbeam-utils", @@ -225,9 +225,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d96137f14f244c37f989d9fff8f95e6c18b918e71f36638f8c49112e4c78f" +checksum = "c3a430a770ebd84726f584a90ee7f020d28db52c6d02138900f22341f866d39c" dependencies = [ "cfg-if", ] @@ -281,9 +281,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0290714b38af9b4a7b094b8a37086d1b4e61f2df9122c3cad2577669145335" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" dependencies = [ "futures-channel", "futures-core", @@ -295,9 +295,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", "futures-sink", @@ -305,44 +305,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" [[package]] name = "futures-io" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf34a163b5c4c52d0478a4d757da8fb65cabef42ba90515efee0f6f9fa45aaa" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" [[package]] name = "futures-macro" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b153fd91e4b0147f4aced87be237c98248656bb01050b96bf3ee89220a8ddb" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ "futures-channel", "futures-core", @@ -388,11 +388,11 @@ checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" [[package]] name = "home" -version = "0.5.5" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -464,9 +464,9 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" [[package]] name = "lsp-server" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb69ba934913ebf0ef3b3dd762f0149bf993decd571d094b646de09c2e456732" +checksum = "248f65b78f6db5d8e1b1604b4098a28b43d21a8eb1deeca22b1c421b276c7095" dependencies = [ "crossbeam-channel", "log", @@ -489,9 +489,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "miniz_oxide" @@ -525,9 +525,9 @@ dependencies = [ [[package]] name = "object" -version = "0.32.1" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" dependencies = [ "memchr", ] @@ -573,7 +573,7 @@ version = "0.1.2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", "syn-solidity", "thiserror", ] @@ -630,7 +630,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -671,18 +671,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.70" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39278fbbf5fb4f646ce651690877f89d1c5811a3d4acb27700c1cb3cdb78fd3b" +checksum = "907a61bd0f64c2f29cd1cf1dc34d05176426a3f504a78010f08416ddb7b13708" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.33" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] @@ -758,29 +758,29 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25dd9975e68d0cb5aa1120c288333fc98731bd1dd12f561e468ea4728c042b89" +checksum = "0b114498256798c94a0689e1a15fec6005dee8ac1f41de56404b67afc2a4b773" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.193" +version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43576ca501357b9b071ac53cdc7da8ef0cbd9493d8df094cd821777ea6e894d3" +checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] name = "serde_json" -version = "1.0.108" +version = "1.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1c7e3eac408d115102c4c24ad393e0821bb3a5df4d506a80f85f7a742a526b" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" dependencies = [ "itoa", "ryu", @@ -789,13 +789,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3081f5ffbb02284dda55132aa26daecedd7372a42417bbbab6f14ab7d6bb9145" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -891,9 +891,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.41" +version = "2.0.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c8b28c477cc3bf0e7966561e3460130e1255f7a1cf71931075f1c5e7a7e269" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" dependencies = [ "proc-macro2", "quote", @@ -909,27 +909,40 @@ dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", +] + +[[package]] +name = "tests-positions-server" +version = "0.3.0" +dependencies = [ + "osmium-libs-lsp-server-wrapper", + "osmium-libs-solidity-ast-extractor", + "regex", + "serde", + "serde_json", + "tokio", + "tower-lsp", ] [[package]] name = "thiserror" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.50" +version = "1.0.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -949,9 +962,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.35.0" +version = "1.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d45b238a16291a4e1584e61820b8ae57d696cc5015c459c229ccc6990cc1c" +checksum = "c89b4efa943be685f629b149f53829423f8f5531ea21249408e8e2f8671ec104" dependencies = [ "backtrace", "bytes", @@ -974,7 +987,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -1042,7 +1055,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] @@ -1070,7 +1083,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.41", + "syn 2.0.48", ] [[package]] diff --git a/toolchains/solidity/core/crates/foundry-compiler-server/src/utils.rs b/toolchains/solidity/core/crates/foundry-compiler-server/src/utils.rs index 9c3f0598..15648f3f 100644 --- a/toolchains/solidity/core/crates/foundry-compiler-server/src/utils.rs +++ b/toolchains/solidity/core/crates/foundry-compiler-server/src/utils.rs @@ -7,7 +7,7 @@ use tower_lsp::lsp_types::{DiagnosticSeverity, InitializeParams}; * @returns {Option} Normalized path */ pub fn get_root_path(params: InitializeParams) -> Option { - if let Some(folder) = params.workspace_folders?.get(0) { + if let Some(folder) = params.workspace_folders?.first() { return Some(normalize_path(folder.uri.path())); } else if let Some(root_uri) = params.root_uri { return Some(normalize_path(root_uri.path())); diff --git a/toolchains/solidity/core/crates/tests-positions-server/Cargo.toml b/toolchains/solidity/core/crates/tests-positions-server/Cargo.toml new file mode 100644 index 00000000..84aa3876 --- /dev/null +++ b/toolchains/solidity/core/crates/tests-positions-server/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tests-positions-server" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +exclude.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +osmium-libs-lsp-server-wrapper = { path = "../../../../../libs/lsp-server-wrapper", version = "0.2.0" } +serde = { version = "1.0.149", features = ["derive"] } +serde_json = "1.0.89" +tokio = {version = "1.34.0", features = ["full"] } +tower-lsp = "0.20.0" +osmium-libs-solidity-ast-extractor = { path = "../../../../../libs/ast-extractor", version = "0.1.2" } +regex = "1.10.2" diff --git a/toolchains/solidity/core/crates/tests-positions-server/src/get_tests_positions.rs b/toolchains/solidity/core/crates/tests-positions-server/src/get_tests_positions.rs new file mode 100644 index 00000000..652934e5 --- /dev/null +++ b/toolchains/solidity/core/crates/tests-positions-server/src/get_tests_positions.rs @@ -0,0 +1,33 @@ +use osmium_libs_lsp_server_wrapper::lsp_types::{request::Request, Range}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetTestsPositionsParams { + pub file_content: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TestContract { + pub name: String, + pub range: Range, + pub tests: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Test { + pub name: String, + pub range: Range, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GetTestsPositionsResponse { + pub contracts: Vec, +} + +pub struct GetTestsPositionsRequest {} + +impl Request for GetTestsPositionsRequest { + type Params = GetTestsPositionsParams; + type Result = GetTestsPositionsResponse; + const METHOD: &'static str = "osmium/getTestsPositions"; +} diff --git a/toolchains/solidity/core/crates/tests-positions-server/src/main.rs b/toolchains/solidity/core/crates/tests-positions-server/src/main.rs new file mode 100644 index 00000000..63152c3f --- /dev/null +++ b/toolchains/solidity/core/crates/tests-positions-server/src/main.rs @@ -0,0 +1,99 @@ +use get_tests_positions::{GetTestsPositionsParams, GetTestsPositionsResponse, Test, TestContract}; +use osmium_libs_solidity_ast_extractor::retriever::retrieve_functions_nodes; +use osmium_libs_solidity_ast_extractor::File; +use osmium_libs_solidity_ast_extractor::{ + extract::extract_ast_from_content, retriever::retrieve_contract_nodes, +}; +use tower_lsp::jsonrpc::{self, Result}; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +mod get_tests_positions; +mod utils; +use utils::range_from_spanned; + +#[derive(Debug)] +struct Backend { + client: Client, +} + +#[tower_lsp::async_trait] +impl LanguageServer for Backend { + async fn initialize(&self, _: InitializeParams) -> Result { + Ok(InitializeResult::default()) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "server initialized!") + .await; + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } +} + +impl Backend { + fn new(client: Client) -> Self { + Self { client } + } + + async fn get_tests_positions( + &self, + params: GetTestsPositionsParams, + ) -> Result { + self.client + .log_message(MessageType::INFO, "Getting tests positions for file") + .await; + let res = extract_ast_from_content(¶ms.file_content); + + if let Ok(ast) = res { + self.extract_tests_positions(ast) + } else { + let err = res.unwrap_err(); + eprintln!("Error: {:?}", err); + Err(jsonrpc::Error::invalid_params(format!("Error: {:?}", err))) + } + } + + pub fn extract_tests_positions(&self, ast: File) -> Result { + let mut res = vec![]; + let re = regex::Regex::new(r"^test.*_.+").unwrap(); + let contracts = retrieve_contract_nodes(&ast); + for contract in contracts { + let mut tests: Vec = vec![]; + let mut functions = retrieve_functions_nodes(&contract); + let contract_tests = functions.iter_mut().filter(|f| { + f.name.is_some() && re.is_match(f.name.as_ref().unwrap().as_string().as_str()) + }); + for test in contract_tests { + let name = match &test.name { + Some(name) => name, + None => continue, + }; + tests.push(Test { + name: name.as_string(), + range: range_from_spanned(name), + }); + } + res.push(TestContract { + name: contract.name.as_string(), + range: range_from_spanned(&contract.name), + tests, + }); + } + Ok(GetTestsPositionsResponse { contracts: res }) + } +} + +#[tokio::main] +async fn main() { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::build(Backend::new) + .custom_method("osmium/getTestsPositions", Backend::get_tests_positions) + .finish(); + Server::new(stdin, stdout, socket).serve(service).await; +} diff --git a/toolchains/solidity/core/crates/tests-positions-server/src/utils.rs b/toolchains/solidity/core/crates/tests-positions-server/src/utils.rs new file mode 100644 index 00000000..c8160328 --- /dev/null +++ b/toolchains/solidity/core/crates/tests-positions-server/src/utils.rs @@ -0,0 +1,19 @@ +use osmium_libs_solidity_ast_extractor::{LineColumn, Spanned}; +use tower_lsp::lsp_types::{Position, Range}; + +pub fn range_from_span(start: LineColumn, end: LineColumn) -> Range { + Range { + start: Position { + line: start.line as u32, + character: start.column as u32, + }, + end: Position { + line: end.line as u32, + character: end.column as u32, + }, + } +} + +pub fn range_from_spanned(spanned: &T) -> Range { + range_from_span(spanned.span().start(), spanned.span().end()) +} diff --git a/toolchains/solidity/extension/package.json b/toolchains/solidity/extension/package.json index d227c4ca..90305130 100644 --- a/toolchains/solidity/extension/package.json +++ b/toolchains/solidity/extension/package.json @@ -16,7 +16,7 @@ "Other" ], "activationEvents": [ - "onLanguage:solidity" + "workspaceContains:solidity" ], "main": "./dist/extension.js", "contributes": { diff --git a/toolchains/solidity/extension/src/extension.ts b/toolchains/solidity/extension/src/extension.ts index c9c43606..f6e1193d 100644 --- a/toolchains/solidity/extension/src/extension.ts +++ b/toolchains/solidity/extension/src/extension.ts @@ -5,20 +5,25 @@ import { import { createLinterClient } from './linter'; import { createFoundryCompilerClient } from './foundry-compiler'; import { createSlitherClient } from './slither'; +import { createTestsPositionsClient } from './tests-positions'; import registerForgeFmtLinter from "./fmt-wrapper"; +import { TestManager } from './tests/test-manager'; let slitherClient: LanguageClient; let linterClient: LanguageClient; let foundryCompilerClient: LanguageClient; +let testsPositionsClient: LanguageClient; +let testManager: TestManager; export async function activate(context: ExtensionContext) { - linterClient = createLinterClient(context); + linterClient = await createLinterClient(context); foundryCompilerClient = createFoundryCompilerClient(context); slitherClient = createSlitherClient(context); + testsPositionsClient = await createTestsPositionsClient(context); + if (workspace.workspaceFolders?.length) + testManager = new TestManager(testsPositionsClient, workspace.workspaceFolders[0].uri.fsPath); - context.subscriptions.push(linterClient); - context.subscriptions.push(foundryCompilerClient); - context.subscriptions.push(slitherClient); + context.subscriptions.push(linterClient, foundryCompilerClient, slitherClient, testsPositionsClient, testManager.testController); registerForgeFmtLinter(context); diff --git a/toolchains/solidity/extension/src/linter.ts b/toolchains/solidity/extension/src/linter.ts index cdf5d193..c8b0aed9 100644 --- a/toolchains/solidity/extension/src/linter.ts +++ b/toolchains/solidity/extension/src/linter.ts @@ -9,7 +9,7 @@ import { } from 'vscode-languageclient/node'; import { TextDecoder } from 'util'; -export function createLinterClient(context: ExtensionContext): LanguageClient { +export async function createLinterClient(context: ExtensionContext): Promise { // The server is implemented in node const serverBinary = context.asAbsolutePath( path.join( @@ -55,7 +55,7 @@ export function createLinterClient(context: ExtensionContext): LanguageClient { }); // Start the client. This will also launch the server - client.start(); + await client.start(); return client; } \ No newline at end of file diff --git a/toolchains/solidity/extension/src/tests-positions.ts b/toolchains/solidity/extension/src/tests-positions.ts new file mode 100644 index 00000000..ca8ae7c8 --- /dev/null +++ b/toolchains/solidity/extension/src/tests-positions.ts @@ -0,0 +1,53 @@ +import * as path from 'path'; +import * as os from 'os'; +import { workspace, ExtensionContext, Uri } from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind +} from 'vscode-languageclient/node'; +import { TextDecoder } from 'util'; + +export async function createTestsPositionsClient(context: ExtensionContext): Promise { + // The server is implemented in node + const serverBinary = context.asAbsolutePath( + path.join( + 'dist', + os.platform().startsWith("win") ? 'tests-positions-server.exe' : 'tests-positions-server' + ) + ); + + // If the extension is launched in debug mode then the debug server options are used + // Otherwise the run options are used + const serverOptions: ServerOptions = { + run: { command: serverBinary, transport: TransportKind.stdio }, + debug: { + command: serverBinary, + transport: TransportKind.stdio, + } + }; + + // Options to control the language client + const clientOptions: LanguageClientOptions = { + // Register the server for plain text documents + //documentSelector: [{ scheme: 'file', language: 'solidity' }], + synchronize: { + // Notify the server about file changes to '.clientrc files contained in the workspace + //fileEvents: workspace.createFileSystemWatcher('**/.solidhunter.json') + } + }; + + // Create the language client and start the client. + const client = new LanguageClient( + 'osmium-tests-positions', + 'Osmium Solidity Tests Positions Language Server', + serverOptions, + clientOptions + ); + + // Start the client. This will also launch the server + await client.start(); + + return client; +} \ No newline at end of file diff --git a/toolchains/solidity/extension/src/tests/foundry-test.ts b/toolchains/solidity/extension/src/tests/foundry-test.ts new file mode 100644 index 00000000..65877a7e --- /dev/null +++ b/toolchains/solidity/extension/src/tests/foundry-test.ts @@ -0,0 +1,125 @@ +import {exec} from 'child_process'; +import * as vscode from "vscode"; + +type TestResult = { + status: string, + reason: string | null, + counterexample: any | null, + logs: any[], + // eslint-disable-next-line @typescript-eslint/naming-convention + decoded_logs: string[] + kind: any, + traces: any + coverage: any + // eslint-disable-next-line @typescript-eslint/naming-convention + labeled_addresses: { + [key: string]: string + } + debug: any | null + breakpoints: any +}; + +type SuiteResult = { + duration: { + nanos: number + secs: number + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + test_results: { + [key: string]: TestResult + }, + warnings: string[] +}; + +type FileResult = { + [key: string]: SuiteResult +}; + +const hasForge = async (workspace: string) => { + return new Promise((resolve, reject) => { + exec('forge --version', { + cwd: workspace + }, (err, stdout, stderr) => { + if (err) { + console.log(err); + vscode.window.showErrorMessage('Forge not found. Please install it and try again.'); + resolve(false); + } else { + resolve(true); + } + }); + }); +}; + +const testAll = async (workspace: string): Promise => { + return new Promise(async (resolve, reject) => { + if (!(await hasForge(workspace))) { + reject("No forge found"); + } + + exec('forge test --json', { + cwd: workspace + }, (error, stdout, stderr) => { + if (error) { // An error is returned by node if the forge test command fails, which is the case if a test fails + if (!stderr.length) { + return resolve(JSON.parse(stdout)); + } + console.log(stderr); + vscode.window.showErrorMessage( + "Error while running forge tests." + ); + reject(stderr); + } else { + resolve(JSON.parse(stdout)); + } + }); + }); +}; + +const testContract = (workspace: string, contractName: string): Promise => { + return new Promise(async (resolve, reject) => { + if (!(await hasForge(workspace))) { + reject("No forge found"); + } + + exec(`forge test --json --match-contract '${contractName}'`, { + cwd: workspace + }, (error, stdout, stderr) => { + if (error) { // An error is returned by node if the forge test command fails, which is the case if a test fails + if (!stderr.length) { + return resolve(JSON.parse(stdout)); + } + console.log(stderr); + vscode.window.showErrorMessage('Error while running forge tests.'); + reject(stderr); + } else { + resolve(JSON.parse(stdout)); + } + }); + }); +}; + +const testFunction = (workspace: string, contractName: string, functionName: string): Promise => { + return new Promise(async (resolve, reject) => { + if (!(await hasForge(workspace))) { + reject("No forge found"); + } + exec(`forge test --json --match-contract '${contractName}' --match-test '${functionName}'`, { + cwd: workspace + }, (error, stdout, stderr) => { + if (error) { // An error is returned by node if the forge test command fails, which is the case if a test fails + if (!stderr.length) { + return resolve(JSON.parse(stdout)); + } + console.log(stderr); + vscode.window.showErrorMessage('Error while running forge tests.'); + reject(stderr); + } else { + resolve(JSON.parse(stdout)); + } + }); + }); +}; + + +export {hasForge, testAll, testContract, testFunction, FileResult, SuiteResult, TestResult}; \ No newline at end of file diff --git a/toolchains/solidity/extension/src/tests/test-manager.ts b/toolchains/solidity/extension/src/tests/test-manager.ts new file mode 100644 index 00000000..2e39ac58 --- /dev/null +++ b/toolchains/solidity/extension/src/tests/test-manager.ts @@ -0,0 +1,279 @@ +import { LanguageClient } from 'vscode-languageclient/node'; +import * as vscode from 'vscode'; +import { testContract, testFunction, FileResult } from './foundry-test'; + +enum ItemType { + file, + contractCase, + testCase +} + +export class TestManager { + public testController: vscode.TestController; + private testData = new WeakMap(); + + constructor(private client: LanguageClient, private workspace: string) { + this.testController = vscode.tests.createTestController("solidityTestController", "Solidity test controller"); + + this.testController.resolveHandler = (test) => { + console.log("controller resolve"); + return this.resolve(test); + }; + this.testController.createRunProfile("Run tests", vscode.TestRunProfileKind.Run, (request, token) => this.runHandler(false, request, token)); + // Uncomment this when debugging is supported + //this.testController.createRunProfile("Debug tests", vscode.TestRunProfileKind.Run, (request, token) => this.runHandler(true, request, token)) + + vscode.workspace.onDidOpenTextDocument((e) => { + this.parseTestsInDocument(e); + }); + + console.log("Test manager created"); + } + + /** + * + * @param _shouldDebug Whether the tests should be run in debug mode + * @param request The TestRunRequest containing the tests to run + * @param token A cancellation token + */ + private async runHandler( + _shouldDebug: boolean, + request: vscode.TestRunRequest, + token: vscode.CancellationToken + ) { + console.log("Run handler called"); + const run = this.testController.createTestRun(request); + const queue: vscode.TestItem[] = []; + + // Loop through all included tests, or all known tests, and add them to our queue + if (request.include) { + console.log("request include", request.include); + request.include.forEach(test => queue.push(test)); + } else { + console.log("testAll"); + this.testController.items.forEach(test => queue.push(test)); + } + + // For every test that was queued, try to run it. Call run.passed() or run.failed(). + // The `TestMessage` can contain extra information, like a failing location or + // a diff output. But here we'll just give it a textual message. + while (queue.length > 0 && !token.isCancellationRequested) { + const test = queue.pop()!; + + // Skip tests the user asked to exclude + if (request.exclude?.includes(test)) { + continue; + } + + const date = Date.now(); + try { + switch (this.testData.get(test)!) { + case ItemType.file: + // If we're running a file and don't know what it contains yet, parse it now + if (test.children.size === 0) { + await this.parseTestsInFileContents(test); + } + break; + case ItemType.contractCase: + //get result form foundry wrapper for contract test + const contractResult = await testContract(this.workspace, test.label); + const contractTime = Date.now() - date; + if (this.analyzeTestResults(contractResult)) { + run.passed(test, contractTime); + } else { + run.failed(test, new vscode.TestMessage("Contract test failed"), contractTime); + } + break; + case ItemType.testCase: + //get result form foundry wrapper for test case + const functionResult = await testFunction(this.workspace, test.parent!.label, test.label); + const functionTime = Date.now() - date; + if (this.analyzeTestResults(functionResult)) { + run.passed(test, functionTime); + } else { + for (const suiteResult of Object.values(functionResult)) { + for (const testResult of Object.values(suiteResult.test_results)) { + run.appendOutput(testResult.decoded_logs[0]); + run.appendOutput(testResult.decoded_logs[1]); + run.appendOutput(testResult.decoded_logs[2]); + } + } + run.failed(test, new vscode.TestMessage("Contract test failed"), functionTime); + } + break; + } + } catch (e: any) { + run.appendOutput(JSON.stringify(e)); + run.failed(test, new vscode.TestMessage("Test failed")); + if (e === "No forge found") { + vscode.window.showErrorMessage("No forge found. Please install forge and make sure it's in your PATH"); + } + } + + // If the test type is a file, we'll queue up all of its children (contracts) to run next. + // Otherwise, we do nothing as the highest level (contracts) already include their children (test cases). + if (this.testData.get(test) === ItemType.file) { + test.children.forEach(test => queue.push(test)); + } + } + + // Make sure to end the run after all tests have been executed: + run.end(); + } + + private analyzeTestResults(result : FileResult) { + let ret = true; + + for (const suiteResult of Object.values(result)) { + for (const testResult of Object.values(suiteResult.test_results)) { + if (testResult.status !== "Success") { + return false; + } + } + } + return true; + + } + + + /** + * Sends a request to the language server to get the positions of all tests in a file + * @param content The content of the file to parse + * @returns A structure containing the positions of all tests in the file (see /toolchains/solidity/core/tests-positions-server/src/get-tests-positions.rs) + */ + private async getTestsPositions(content: string): Promise { + console.log("getTestsPositions"); + return this.client.sendRequest('osmium/getTestsPositions', { + file_content: content // eslint-disable-line @typescript-eslint/naming-convention + }); + } + + /** + * Check if a TestItem for a file already exists in the testController, and if not, create it + * @param uri URI of the file to get or create a TestItem for + * @returns The TestItem for the file + */ + private getOrCreateTestFileItem(uri: vscode.Uri) { + console.log("getOrCreateTestFileItem"); + const existing = this.testController.items.get(uri.toString()); + if (existing) { + return existing; + } + + const file = this.testController.createTestItem(uri.toString(), uri.path.split('/').pop()!, uri); + this.testData.set(file, ItemType.file); + file.canResolveChildren = true; + this.testController.items.add(file); + return file; + } + + /** + * Resolve a TestItem. If it's a file, parse it for tests. If it's a contract, parse it for tests and add them as children + * @param test The TestItem to resolve + */ + private async resolve(test?: vscode.TestItem) { + if (!test) { + await this.discoverAllFilesInWorkspace(); + } else { + await this.parseTestsInFileContents(test); + } + } + + /** + * Discover all files in the workspace and add them to the testController + * Also create a FileSystemWatcher for each file to watch for changes + */ + private async discoverAllFilesInWorkspace() { + if (!vscode.workspace.workspaceFolders) { + return []; // handle the case of no open folders + } + + return Promise.all( + vscode.workspace.workspaceFolders.map(async workspaceFolder => { + const pattern = new vscode.RelativePattern(workspaceFolder, '**/*.t.sol'); + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + + // When files are created, make sure there's a corresponding "file" node in the tree + watcher.onDidCreate(uri => this.getOrCreateTestFileItem(uri)); + // When files change, re-parse them. Note that you could optimize this so + // that you only re-parse children that have been resolved in the past. + watcher.onDidChange(uri => this.parseTestsInFileContents(this.getOrCreateTestFileItem(uri))); + // And, finally, delete TestItems for removed files. This is simple, since + // we use the URI as the TestItem's ID. + watcher.onDidDelete(uri => this.testController.items.delete(uri.toString())); + + for (const file of await vscode.workspace.findFiles(pattern)) { + this.getOrCreateTestFileItem(file); + } + + return watcher; + }) + ); + } + + /** + * Check if the document is a test file and parse it if it is + * @param e TextDocument that was opened + */ + private parseTestsInDocument(e: vscode.TextDocument) { + if (e.uri.scheme === 'file' && e.uri.path.endsWith('.t.sol')) { + this.parseTestsInFileContents(this.getOrCreateTestFileItem(e.uri), e.getText()); + } + } + + /** + * Read the contents of a file and parse it for tests by calling the tests-positions language server method. It will then fill the children of the TestItem with the tests found. + * @param file A TestItem representing the file to parse + * @param contents The contents of the file. If not provided, the file will be read from disk + */ + private async parseTestsInFileContents(file: vscode.TestItem, contents?: string) { + // If a document is open, VS Code already knows its contents. If this is being + // called from the resolveHandler when a document isn't open, we'll need to + // read them from disk ourselves. + if (contents === undefined) { + const rawContent = await vscode.workspace.fs.readFile(file.uri!); + contents = new TextDecoder().decode(rawContent); + } + + if (contents !== undefined) { + // CALL getTestPositions and fill children + await this.getTestsPositions(contents) + .then((testPositions) => { + testPositions.contracts.forEach((contract: any) => { + const contractName = contract.name.replace(" ", ""); + const contractItem = this.testController.createTestItem(contractName, contract.name, file.uri); + contractItem.range = convertRange(contract.range); + console.log("Contract range", JSON.stringify(contractItem.range)); + this.testData.set(contractItem, ItemType.contractCase); + file.children.add(contractItem); + + contract.tests.forEach((test: any) => { + const functionItem = this.testController.createTestItem(`${contractName}_${test.name}`, test.name, file.uri); + functionItem.range = convertRange(test.range); + console.log("Test range", JSON.stringify(functionItem.range)); + this.testData.set(functionItem, ItemType.testCase); + contractItem.children.add(functionItem); + }); + }); + }) + .catch((error) => { + console.log("Error getting tests positions", error); + vscode.window.showErrorMessage("Error while getting tests positions"); + }); + } + } +} + +/** + * Convert a LSP range to a VSCode range (offsets are 0-based in VScode and 1-based in LSP) + * @param lspRange LSP range + * @returns A VSCode range with the same start and end positions + */ +function convertRange(lspRange: any): vscode.Range { + const range = new vscode.Range( + new vscode.Position(lspRange.start.line - 1, lspRange.start.character), + new vscode.Position(lspRange.end.line - 1, lspRange.end.character), + ); + console.log(range); + return range; +} \ No newline at end of file