diff --git a/Cargo.lock b/Cargo.lock index 563681123ed..1f209a733f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2270,6 +2270,7 @@ dependencies = [ "noirc_driver", "noirc_errors", "noirc_frontend", + "serde", "serde_json", "tokio", "toml", diff --git a/compiler/noirc_frontend/src/hir/def_map/mod.rs b/compiler/noirc_frontend/src/hir/def_map/mod.rs index 4c7f241efaa..2d5f7f38191 100644 --- a/compiler/noirc_frontend/src/hir/def_map/mod.rs +++ b/compiler/noirc_frontend/src/hir/def_map/mod.rs @@ -139,9 +139,10 @@ impl CrateDefMap { self.modules.iter().flat_map(|(_, module)| { module.value_definitions().filter_map(|id| { if let Some(func_id) = id.as_function() { - match interner.function_meta(&func_id).attributes.primary { + let func_meta = interner.function_meta(&func_id); + match func_meta.attributes.primary { Some(PrimaryAttribute::Test(scope)) => { - Some(TestFunction::new(func_id, scope)) + Some(TestFunction::new(func_id, scope, func_meta.name.location)) } _ => None, } @@ -239,11 +240,12 @@ impl std::ops::IndexMut for CrateDefMap { pub struct TestFunction { id: FuncId, scope: TestScope, + location: Location, } impl TestFunction { - fn new(id: FuncId, scope: TestScope) -> Self { - TestFunction { id, scope } + fn new(id: FuncId, scope: TestScope, location: Location) -> Self { + TestFunction { id, scope, location } } /// Returns the function id of the test function @@ -251,6 +253,10 @@ impl TestFunction { self.id } + pub fn file_id(&self) -> FileId { + self.location.file + } + /// Returns true if the test function has been specified to fail /// This is done by annotating the function with `#[test(should_fail)]` /// or `#[test(should_fail_with = "reason")]` diff --git a/tooling/lsp/Cargo.toml b/tooling/lsp/Cargo.toml index 00f4f9f9d82..eec6d34b912 100644 --- a/tooling/lsp/Cargo.toml +++ b/tooling/lsp/Cargo.toml @@ -18,6 +18,7 @@ nargo_toml.workspace = true noirc_driver.workspace = true noirc_errors.workspace = true noirc_frontend.workspace = true +serde.workspace = true serde_json.workspace = true toml.workspace = true tower.workspace = true diff --git a/tooling/lsp/src/lib.rs b/tooling/lsp/src/lib.rs index 00381e79a82..ef06c3c291a 100644 --- a/tooling/lsp/src/lib.rs +++ b/tooling/lsp/src/lib.rs @@ -12,21 +12,32 @@ use async_lsp::{ }; use codespan_reporting::files; use fm::FILE_EXTENSION; -use lsp_types::{ - notification, request, CodeLens, CodeLensOptions, CodeLensParams, Command, Diagnostic, - DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, - InitializeParams, InitializeResult, InitializedParams, LogMessageParams, MessageType, Position, - PublishDiagnosticsParams, Range, ServerCapabilities, TextDocumentSyncOptions, +use nargo::{ + ops::{run_test, TestStatus}, + prepare_package, }; -use nargo::prepare_package; use nargo_toml::{find_package_manifest, resolve_workspace_from_toml, PackageSelection}; -use noirc_driver::check_crate; +use noirc_driver::{check_crate, CompileOptions}; use noirc_errors::{DiagnosticKind, FileDiagnostic}; -use noirc_frontend::hir::FunctionNameMatch; +use noirc_frontend::{ + graph::{CrateId, CrateName}, + hir::{Context, FunctionNameMatch}, +}; use serde_json::Value as JsonValue; use tower::Service; +mod types; + +use types::{ + notification, request, CodeLens, CodeLensOptions, CodeLensParams, CodeLensResult, Command, + Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, + DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, + InitializeParams, InitializeResult, InitializedParams, LogMessageParams, MessageType, + NargoCapability, NargoPackageTests, NargoTest, NargoTestId, NargoTestRunParams, + NargoTestRunResult, NargoTestsOptions, NargoTestsParams, NargoTestsResult, Position, + PublishDiagnosticsParams, Range, ServerCapabilities, TextDocumentSyncOptions, Url, +}; + const ARROW: &str = "▶\u{fe0e}"; const TEST_COMMAND: &str = "nargo.test"; const TEST_CODELENS_TITLE: &str = "Run Test"; @@ -58,7 +69,9 @@ impl NargoLspService { router .request::(on_initialize) .request::(on_shutdown) - .request::(on_code_lens_request) + .request::(on_code_lens_request) + .request::(on_tests_request) + .request::(on_test_run_request) .notification::(on_initialized) .notification::(on_did_change_configuration) .notification::(on_did_open_text_document) @@ -120,18 +133,184 @@ fn on_initialize( let code_lens = CodeLensOptions { resolve_provider: Some(false) }; + let nargo = NargoCapability { + tests: Some(NargoTestsOptions { + fetch: Some(true), + run: Some(true), + update: Some(true), + }), + }; + Ok(InitializeResult { capabilities: ServerCapabilities { text_document_sync: Some(text_document_sync.into()), code_lens_provider: Some(code_lens), - // Add capabilities before this spread when adding support for one - ..Default::default() + nargo: Some(nargo), }, server_info: None, }) } } +fn on_test_run_request( + state: &mut LspState, + params: NargoTestRunParams, +) -> impl Future> { + let root_path = match &state.root_path { + Some(root) => root, + None => { + return future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + "Could not find project root", + ))) + } + }; + + let toml_path = match find_package_manifest(root_path, root_path) { + Ok(toml_path) => toml_path, + Err(err) => { + // If we cannot find a manifest, we can't run the test + return future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + format!("{}", err), + ))); + } + }; + + let crate_name = params.id.crate_name(); + let function_name = params.id.function_name(); + + let workspace = match resolve_workspace_from_toml( + &toml_path, + PackageSelection::Selected(crate_name.clone()), + ) { + Ok(workspace) => workspace, + Err(err) => { + // If we found a manifest, but the workspace is invalid, we raise an error about it + return future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + format!("{}", err), + ))); + } + }; + + // Since we filtered on crate name, this should be the only item in the iterator + match workspace.into_iter().next() { + Some(package) => { + let (mut context, crate_id) = prepare_package(package); + if check_crate(&mut context, crate_id, false).is_err() { + let result = NargoTestRunResult { + id: params.id.clone(), + result: "error".to_string(), + message: Some("The project failed to compile".into()), + }; + return future::ready(Ok(result)); + }; + + let test_functions = context.get_all_test_functions_in_crate_matching( + &crate_id, + FunctionNameMatch::Exact(function_name), + ); + + match test_functions.into_iter().next() { + Some((_, test_function)) => { + #[allow(deprecated)] + let blackbox_solver = acvm::blackbox_solver::BarretenbergSolver::new(); + let test_result = run_test( + &blackbox_solver, + &context, + test_function, + false, + &CompileOptions::default(), + ); + let result = match test_result { + TestStatus::Pass => NargoTestRunResult { + id: params.id.clone(), + result: "pass".to_string(), + message: None, + }, + TestStatus::Fail { message } => NargoTestRunResult { + id: params.id.clone(), + result: "fail".to_string(), + message: Some(message), + }, + TestStatus::CompileError(diag) => NargoTestRunResult { + id: params.id.clone(), + result: "error".to_string(), + message: Some(diag.diagnostic.message), + }, + }; + future::ready(Ok(result)) + } + None => future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + format!("Could not locate test named: {function_name} in {crate_name}"), + ))), + } + } + None => future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + format!("Could not locate package named: {crate_name}"), + ))), + } +} + +fn on_tests_request( + state: &mut LspState, + _params: NargoTestsParams, +) -> impl Future> { + let root_path = match &state.root_path { + Some(root) => root, + None => { + return future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + "Could not find project root", + ))) + } + }; + + let toml_path = match find_package_manifest(root_path, root_path) { + Ok(toml_path) => toml_path, + Err(err) => { + // If we cannot find a manifest, we log a warning but return no tests + // We can reconsider this when we can build a file without the need for a Nargo.toml file to resolve deps + let _ = state.client.log_message(LogMessageParams { + typ: MessageType::WARNING, + message: format!("{}", err), + }); + return future::ready(Ok(None)); + } + }; + let workspace = match resolve_workspace_from_toml(&toml_path, PackageSelection::All) { + Ok(workspace) => workspace, + Err(err) => { + // If we found a manifest, but the workspace is invalid, we raise an error about it + return future::ready(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + format!("{}", err), + ))); + } + }; + + let mut package_tests = Vec::new(); + + for package in &workspace { + let (mut context, crate_id) = prepare_package(package); + // We ignore the warnings and errors produced by compilation for producing tests + // because we can still get the test functions even if compilation fails + let _ = check_crate(&mut context, crate_id, false); + + // We don't add test headings for a package if it contains no `#[test]` functions + if let Some(tests) = get_package_tests_in_crate(&context, &crate_id, &package.name) { + package_tests.push(NargoPackageTests { package: package.name.to_string(), tests }); + } + } + + let res = if package_tests.is_empty() { Ok(None) } else { Ok(Some(package_tests)) }; + + future::ready(res) +} + fn on_shutdown( _state: &mut LspState, _params: (), @@ -142,7 +321,7 @@ fn on_shutdown( fn on_code_lens_request( state: &mut LspState, params: CodeLensParams, -) -> impl Future>, ResponseError>> { +) -> impl Future> { let file_path = match params.text_document.uri.to_file_path() { Ok(file_path) => file_path, Err(()) => { @@ -415,6 +594,14 @@ fn on_did_save_text_document( Err(errors_and_warnings) => errors_and_warnings, }; + // We don't add test headings for a package if it contains no `#[test]` functions + if let Some(tests) = get_package_tests_in_crate(&context, &crate_id, &package.name) { + let _ = state.client.notify::(NargoPackageTests { + package: package.name.to_string(), + tests, + }); + } + if !file_diagnostics.is_empty() { let fm = &context.file_manager; let files = fm.as_file_map(); @@ -445,7 +632,7 @@ fn on_did_save_text_document( range, severity, message: diagnostic.message, - ..Diagnostic::default() + ..Default::default() }) } } @@ -467,12 +654,46 @@ fn on_exit(_state: &mut LspState, _params: ()) -> ControlFlow Option> { + let fm = &context.file_manager; + let files = fm.as_file_map(); + let tests = + context.get_all_test_functions_in_crate_matching(crate_id, FunctionNameMatch::Anything); + + let mut package_tests = Vec::new(); + + for (func_name, test_function) in tests { + let location = context.function_meta(&test_function.get_id()).name.location; + let file_id = location.file; + + let file_path = fm.path(file_id).with_extension(FILE_EXTENSION); + let range = byte_span_to_range(files, file_id, location.span.into()).unwrap_or_default(); + + package_tests.push(NargoTest { + id: NargoTestId::new(crate_name.clone(), func_name.clone()), + label: func_name, + uri: Url::from_file_path(file_path) + .expect("Expected a valid file path that can be converted into a URI"), + range, + }) + } + + if package_tests.is_empty() { + None + } else { + Some(package_tests) + } +} + fn byte_span_to_range<'a, F: files::Files<'a> + ?Sized>( files: &'a F, file_id: F::FileId, span: ops::Range, ) -> Option { - // TODO(#1683): Codespan ranges are often (always?) off by some amount of characters if let Ok(codespan_range) = codespan_lsp::byte_span_to_range(files, file_id, span) { // We have to manually construct a Range because the codespan_lsp restricts lsp-types to the wrong version range // TODO: codespan is unmaintained and we should probably subsume it. Ref https://github.com/brendanzab/codespan/issues/345 diff --git a/tooling/lsp/src/types.rs b/tooling/lsp/src/types.rs new file mode 100644 index 00000000000..10f1764c63f --- /dev/null +++ b/tooling/lsp/src/types.rs @@ -0,0 +1,190 @@ +use noirc_frontend::graph::CrateName; +use serde::{Deserialize, Serialize}; + +// Re-providing lsp_types that we don't need to override +pub(crate) use lsp_types::{ + CodeLens, CodeLensOptions, CodeLensParams, Command, Diagnostic, DiagnosticSeverity, + DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializedParams, + LogMessageParams, MessageType, Position, PublishDiagnosticsParams, Range, ServerInfo, + TextDocumentSyncCapability, TextDocumentSyncOptions, Url, +}; + +pub(crate) mod request { + use lsp_types::{request::Request, InitializeParams}; + + use super::{ + InitializeResult, NargoTestRunParams, NargoTestRunResult, NargoTestsParams, + NargoTestsResult, + }; + + // Re-providing lsp_types that we don't need to override + pub(crate) use lsp_types::request::{CodeLensRequest as CodeLens, Shutdown}; + + #[derive(Debug)] + pub(crate) struct Initialize; + impl Request for Initialize { + type Params = InitializeParams; + type Result = InitializeResult; + const METHOD: &'static str = "initialize"; + } + + #[derive(Debug)] + pub(crate) struct NargoTestRun; + impl Request for NargoTestRun { + type Params = NargoTestRunParams; + type Result = NargoTestRunResult; + const METHOD: &'static str = "nargo/tests/run"; + } + + #[derive(Debug)] + pub(crate) struct NargoTests; + impl Request for NargoTests { + type Params = NargoTestsParams; + type Result = NargoTestsResult; + const METHOD: &'static str = "nargo/tests"; + } +} + +pub(crate) mod notification { + use lsp_types::notification::Notification; + + use super::NargoPackageTests; + + // Re-providing lsp_types that we don't need to override + pub(crate) use lsp_types::notification::{ + DidChangeConfiguration, DidChangeTextDocument, DidCloseTextDocument, DidOpenTextDocument, + DidSaveTextDocument, Exit, Initialized, + }; + + pub(crate) struct NargoUpdateTests; + impl Notification for NargoUpdateTests { + type Params = NargoPackageTests; + const METHOD: &'static str = "nargo/tests/update"; + } +} + +#[derive(Debug, Eq, PartialEq, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NargoTestsOptions { + /// Tests can be requested from the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) fetch: Option, + + /// Tests runs can be requested from the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) run: Option, + + /// The server will send notifications to update tests. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) update: Option, +} + +#[derive(Debug, Eq, PartialEq, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NargoCapability { + /// The server will provide various features related to testing within Nargo. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tests: Option, +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ServerCapabilities { + /// Defines how text documents are synced. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text_document_sync: Option, + + /// The server provides code lens. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) code_lens_provider: Option, + + /// The server handles and provides custom nargo messages. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) nargo: Option, +} + +#[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct InitializeResult { + /// The capabilities the language server provides. + pub(crate) capabilities: ServerCapabilities, + + /// Information about the server. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) server_info: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub(crate) struct NargoTestId { + package: CrateName, + fully_qualified_path: String, +} + +impl TryFrom for NargoTestId { + type Error = String; + + fn try_from(value: String) -> Result { + if let Some((crate_name, function_name)) = value.split_once('/') { + let crate_name = crate_name.parse()?; + Ok(Self { package: crate_name, fully_qualified_path: function_name.to_string() }) + } else { + Err("NargoTestId should be serialized as package_name/fully_qualified_path".to_string()) + } + } +} + +impl From for String { + fn from(value: NargoTestId) -> Self { + format!("{}/{}", value.package, value.fully_qualified_path) + } +} + +impl NargoTestId { + pub(crate) fn new(crate_name: CrateName, function_name: String) -> Self { + Self { package: crate_name, fully_qualified_path: function_name } + } + + pub(crate) fn crate_name(&self) -> &CrateName { + &self.package + } + + pub(crate) fn function_name(&self) -> &String { + &self.fully_qualified_path + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct NargoTest { + pub(crate) id: NargoTestId, + /// Fully-qualified path to the test within the crate + pub(crate) label: String, + pub(crate) range: Range, + pub(crate) uri: Url, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct NargoPackageTests { + pub(crate) package: String, + pub(crate) tests: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct NargoTestsParams {} + +pub(crate) type NargoTestsResult = Option>; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct NargoTestRunParams { + pub(crate) id: NargoTestId, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct NargoTestRunResult { + pub(crate) id: NargoTestId, + pub(crate) result: String, + pub(crate) message: Option, +} + +pub(crate) type CodeLensResult = Option>;