diff --git a/Cargo.lock b/Cargo.lock index 122a685ba61..26945d4ab8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1130,9 +1130,9 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200" +checksum = "14c3242926edf34aec4ac3a77108ad4854bffaa2e4ddc1824124ce59231302d5" dependencies = [ "cfg-if", "crossbeam-utils", @@ -1174,9 +1174,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294" +checksum = "c06d96137f14f244c37f989d9fff8f95e6c18b918e71f36638f8c49112e4c78f" dependencies = [ "cfg-if", ] @@ -2596,6 +2596,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "thiserror", "tokio", "tower", "wasm-bindgen", diff --git a/tooling/lsp/Cargo.toml b/tooling/lsp/Cargo.toml index 5f5e701da67..6371bcbac19 100644 --- a/tooling/lsp/Cargo.toml +++ b/tooling/lsp/Cargo.toml @@ -23,6 +23,7 @@ serde_json.workspace = true tower.workspace = true async-lsp = { workspace = true, features = ["omni-trait"] } serde_with = "3.2.0" +thiserror.workspace = true fm.workspace = true [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] diff --git a/tooling/lsp/src/lib.rs b/tooling/lsp/src/lib.rs index 2ad8096a13f..9887e5b8e96 100644 --- a/tooling/lsp/src/lib.rs +++ b/tooling/lsp/src/lib.rs @@ -7,7 +7,7 @@ use std::{ collections::HashMap, future::Future, ops::{self, ControlFlow}, - path::PathBuf, + path::{Path, PathBuf}, pin::Pin, task::{self, Poll}, }; @@ -18,19 +18,27 @@ use async_lsp::{ ResponseError, }; use fm::codespan_files as files; +use lsp_types::CodeLens; +use nargo::workspace::Workspace; +use nargo_toml::{find_file_manifest, resolve_workspace_from_toml, PackageSelection}; +use noirc_driver::NOIR_ARTIFACT_VERSION_STRING; use noirc_frontend::{ graph::{CrateId, CrateName}, hir::{Context, FunctionNameMatch}, }; + +use fm::FileManager; + use notifications::{ on_did_change_configuration, on_did_change_text_document, on_did_close_text_document, on_did_open_text_document, on_did_save_text_document, on_exit, on_initialized, }; use requests::{ - on_formatting, on_goto_definition_request, on_initialize, on_profile_run_request, on_shutdown, - on_test_run_request, on_tests_request, + on_code_lens_request, on_formatting, on_goto_definition_request, on_initialize, + on_profile_run_request, on_shutdown, on_test_run_request, on_tests_request, }; use serde_json::Value as JsonValue; +use thiserror::Error; use tower::Service; mod notifications; @@ -41,12 +49,20 @@ mod types; use solver::WrapperSolver; use types::{notification, request, NargoTest, NargoTestId, Position, Range, Url}; +#[derive(Debug, Error)] +pub enum LspError { + /// Error while Resolving Workspace. + #[error("Failed to Resolve Workspace - {0}")] + WorkspaceResolutionError(String), +} + // State for the LSP gets implemented on this struct and is internal to the implementation pub struct LspState { root_path: Option, client: ClientSocket, solver: WrapperSolver, input_files: HashMap, + cached_lenses: HashMap>, } impl LspState { @@ -56,6 +72,7 @@ impl LspState { root_path: None, solver: WrapperSolver(Box::new(solver)), input_files: HashMap::new(), + cached_lenses: HashMap::new(), } } } @@ -72,6 +89,7 @@ impl NargoLspService { .request::(on_initialize) .request::(on_formatting) .request::(on_shutdown) + .request::(on_code_lens_request) .request::(on_tests_request) .request::(on_test_run_request) .request::(on_profile_run_request) @@ -175,3 +193,60 @@ fn byte_span_to_range<'a, F: files::Files<'a> + ?Sized>( None } } + +pub(crate) fn resolve_workspace_for_source_path(file_path: &Path) -> Result { + let package_root = find_file_manifest(file_path); + + let toml_path = package_root.ok_or_else(|| { + LspError::WorkspaceResolutionError(format!( + "Nargo.toml not found for file: {:?}", + file_path + )) + })?; + + let workspace = resolve_workspace_from_toml( + &toml_path, + PackageSelection::All, + Some(NOIR_ARTIFACT_VERSION_STRING.to_string()), + ) + .map_err(|err| LspError::WorkspaceResolutionError(err.to_string()))?; + + Ok(workspace) +} + +/// Prepares a package from a source string +/// This is useful for situations when we don't need dependencies +/// and just need to operate on single file. +/// +/// Use case for this is the LSP server and code lenses +/// which operate on single file and need to understand this file +/// in order to offer code lenses to the user +fn prepare_source(source: String) -> (Context<'static>, CrateId) { + let root = Path::new(""); + let mut file_manager = FileManager::new(root); + let root_file_id = file_manager.add_file_with_source(Path::new("main.nr"), source).expect( + "Adding source buffer to file manager should never fail when file manager is empty", + ); + + let mut context = Context::new(file_manager); + + let root_crate_id = context.crate_graph.add_crate_root(root_file_id); + + (context, root_crate_id) +} + +#[test] +fn prepare_package_from_source_string() { + let source = r#" + fn main() { + let x = 1; + let y = 2; + let z = x + y; + } + "#; + + let (mut context, crate_id) = crate::prepare_source(source.to_string()); + let _check_result = noirc_driver::check_crate(&mut context, crate_id, false, false); + let main_func_id = context.get_main_function(&crate_id); + assert!(main_func_id.is_some()); +} diff --git a/tooling/lsp/src/notifications/mod.rs b/tooling/lsp/src/notifications/mod.rs index d0ca8679e7e..6cefaa134ce 100644 --- a/tooling/lsp/src/notifications/mod.rs +++ b/tooling/lsp/src/notifications/mod.rs @@ -2,18 +2,20 @@ use std::ops::ControlFlow; use async_lsp::{ErrorCode, LanguageClient, ResponseError}; use nargo::{insert_all_files_for_workspace_into_file_manager, prepare_package}; -use nargo_toml::{find_file_manifest, resolve_workspace_from_toml, PackageSelection}; -use noirc_driver::{check_crate, file_manager_with_stdlib, NOIR_ARTIFACT_VERSION_STRING}; +use noirc_driver::{check_crate, file_manager_with_stdlib}; use noirc_errors::{DiagnosticKind, FileDiagnostic}; +use crate::requests::collect_lenses_for_package; use crate::types::{ notification, Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, - DidSaveTextDocumentParams, InitializedParams, LogMessageParams, MessageType, NargoPackageTests, - PublishDiagnosticsParams, + DidSaveTextDocumentParams, InitializedParams, NargoPackageTests, PublishDiagnosticsParams, }; -use crate::{byte_span_to_range, get_package_tests_in_crate, LspState}; +use crate::{ + byte_span_to_range, get_package_tests_in_crate, prepare_source, + resolve_workspace_for_source_path, LspState, +}; pub(super) fn on_initialized( _state: &mut LspState, @@ -42,7 +44,38 @@ pub(super) fn on_did_change_text_document( params: DidChangeTextDocumentParams, ) -> ControlFlow> { let text = params.content_changes.into_iter().next().unwrap().text; - state.input_files.insert(params.text_document.uri.to_string(), text); + state.input_files.insert(params.text_document.uri.to_string(), text.clone()); + + let (mut context, crate_id) = prepare_source(text); + let _ = check_crate(&mut context, crate_id, false, false); + + let workspace = match resolve_workspace_for_source_path( + params.text_document.uri.to_file_path().unwrap().as_path(), + ) { + Ok(workspace) => workspace, + Err(lsp_error) => { + return ControlFlow::Break(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + lsp_error.to_string(), + ) + .into())) + } + }; + let package = match workspace.members.first() { + Some(package) => package, + None => { + return ControlFlow::Break(Err(ResponseError::new( + ErrorCode::REQUEST_FAILED, + "Selected workspace has no members", + ) + .into())) + } + }; + + let lenses = collect_lenses_for_package(&context, crate_id, &workspace, package, None); + + state.cached_lenses.insert(params.text_document.uri.to_string(), lenses); + ControlFlow::Continue(()) } @@ -51,6 +84,7 @@ pub(super) fn on_did_close_text_document( params: DidCloseTextDocumentParams, ) -> ControlFlow> { state.input_files.remove(¶ms.text_document.uri.to_string()); + state.cached_lenses.remove(¶ms.text_document.uri.to_string()); ControlFlow::Continue(()) } @@ -69,34 +103,14 @@ pub(super) fn on_did_save_text_document( } }; - let package_root = find_file_manifest(file_path.as_path()); - - let toml_path = match package_root { - Some(toml_path) => toml_path, - None => { - // If we cannot find a manifest, we log a warning but return no diagnostics - // 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!("Nargo.toml not found for file: {:}", file_path.display()), - }); - return ControlFlow::Continue(()); - } - }; - - let workspace = match resolve_workspace_from_toml( - &toml_path, - PackageSelection::All, - Some(NOIR_ARTIFACT_VERSION_STRING.to_string()), - ) { - Ok(workspace) => workspace, - Err(err) => { - // If we found a manifest, but the workspace is invalid, we raise an error about it + let workspace = match resolve_workspace_for_source_path(&file_path) { + Ok(value) => value, + Err(lsp_error) => { return ControlFlow::Break(Err(ResponseError::new( ErrorCode::REQUEST_FAILED, - format!("{err}"), + lsp_error.to_string(), ) - .into())); + .into())) } }; @@ -121,6 +135,15 @@ pub(super) fn on_did_save_text_document( }); } + let collected_lenses = crate::requests::collect_lenses_for_package( + &context, + crate_id, + &workspace, + package, + Some(&file_path), + ); + state.cached_lenses.insert(params.text_document.uri.to_string(), collected_lenses); + let fm = &context.file_manager; let files = fm.as_file_map(); diff --git a/tooling/lsp/src/requests/code_lens_request.rs b/tooling/lsp/src/requests/code_lens_request.rs new file mode 100644 index 00000000000..4b1d38a137e --- /dev/null +++ b/tooling/lsp/src/requests/code_lens_request.rs @@ -0,0 +1,236 @@ +use std::future::{self, Future}; + +use async_lsp::{ErrorCode, ResponseError}; + +use nargo::{package::Package, workspace::Workspace}; +use noirc_driver::check_crate; +use noirc_frontend::hir::FunctionNameMatch; + +use crate::{ + byte_span_to_range, prepare_source, resolve_workspace_for_source_path, + types::{CodeLens, CodeLensParams, CodeLensResult, Command}, + LspState, +}; + +const ARROW: &str = "▶\u{fe0e}"; +const TEST_COMMAND: &str = "nargo.test"; +const TEST_CODELENS_TITLE: &str = "Run Test"; +const COMPILE_COMMAND: &str = "nargo.compile"; +const COMPILE_CODELENS_TITLE: &str = "Compile"; +const INFO_COMMAND: &str = "nargo.info"; +const INFO_CODELENS_TITLE: &str = "Info"; +const EXECUTE_COMMAND: &str = "nargo.execute"; +const EXECUTE_CODELENS_TITLE: &str = "Execute"; + +const PROFILE_COMMAND: &str = "nargo.profile"; +const PROFILE_CODELENS_TITLE: &str = "Profile"; + +fn with_arrow(title: &str) -> String { + format!("{ARROW} {title}") +} + +fn package_selection_args(workspace: &Workspace, package: &Package) -> Vec { + vec![ + "--program-dir".into(), + workspace.root_dir.display().to_string().into(), + "--package".into(), + package.name.to_string().into(), + ] +} + +pub(crate) fn on_code_lens_request( + state: &mut LspState, + params: CodeLensParams, +) -> impl Future> { + future::ready(on_code_lens_request_inner(state, params)) +} + +fn on_code_lens_request_inner( + state: &mut LspState, + params: CodeLensParams, +) -> Result { + let file_path = params.text_document.uri.to_file_path().map_err(|_| { + ResponseError::new(ErrorCode::REQUEST_FAILED, "URI is not a valid file path") + })?; + + if let Some(collected_lenses) = state.cached_lenses.get(¶ms.text_document.uri.to_string()) { + return Ok(Some(collected_lenses.clone())); + } + + let source_string = std::fs::read_to_string(&file_path).map_err(|_| { + ResponseError::new(ErrorCode::REQUEST_FAILED, "Could not read file from disk") + })?; + + let workspace = resolve_workspace_for_source_path(file_path.as_path()).unwrap(); + let package = workspace.members.first().unwrap(); + + let (mut context, crate_id) = prepare_source(source_string); + // We ignore the warnings and errors produced by compilation for producing code lenses + // because we can still get the test functions even if compilation fails + let _ = check_crate(&mut context, crate_id, false, false); + + let collected_lenses = + collect_lenses_for_package(&context, crate_id, &workspace, package, None); + + if collected_lenses.is_empty() { + state.cached_lenses.remove(¶ms.text_document.uri.to_string()); + Ok(None) + } else { + state + .cached_lenses + .insert(params.text_document.uri.to_string().clone(), collected_lenses.clone()); + Ok(Some(collected_lenses)) + } +} + +pub(crate) fn collect_lenses_for_package( + context: &noirc_frontend::macros_api::HirContext, + crate_id: noirc_frontend::macros_api::CrateId, + workspace: &Workspace, + package: &Package, + file_path: Option<&std::path::PathBuf>, +) -> Vec { + let mut lenses: Vec = vec![]; + 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); + for (func_name, test_function) in tests { + let location = context.function_meta(&test_function.get_id()).name.location; + let file_id = location.file; + + // Ignore diagnostics for any file that wasn't the file we saved + // TODO: In the future, we could create "related" diagnostics for these files + if let Some(file_path) = file_path { + if fm.path(file_id) != *file_path { + continue; + } + } + + let range = byte_span_to_range(files, file_id, location.span.into()).unwrap_or_default(); + + let test_command = Command { + title: with_arrow(TEST_CODELENS_TITLE), + command: TEST_COMMAND.into(), + arguments: Some( + [ + package_selection_args(workspace, package), + vec!["--exact".into(), func_name.into()], + ] + .concat(), + ), + }; + + let test_lens = CodeLens { range, command: Some(test_command), data: None }; + + lenses.push(test_lens); + } + if package.is_binary() { + if let Some(main_func_id) = context.get_main_function(&crate_id) { + let location = context.function_meta(&main_func_id).name.location; + let file_id = location.file; + + // Ignore diagnostics for any file that wasn't the file we saved + // TODO: In the future, we could create "related" diagnostics for these files + if let Some(file_path) = file_path { + if fm.path(file_id) != *file_path { + return lenses; + } + } + + let range = + byte_span_to_range(files, file_id, location.span.into()).unwrap_or_default(); + + let compile_command = Command { + title: with_arrow(COMPILE_CODELENS_TITLE), + command: COMPILE_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let compile_lens = CodeLens { range, command: Some(compile_command), data: None }; + + lenses.push(compile_lens); + + let info_command = Command { + title: INFO_CODELENS_TITLE.to_string(), + command: INFO_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let info_lens = CodeLens { range, command: Some(info_command), data: None }; + + lenses.push(info_lens); + + let execute_command = Command { + title: EXECUTE_CODELENS_TITLE.to_string(), + command: EXECUTE_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let execute_lens = CodeLens { range, command: Some(execute_command), data: None }; + + lenses.push(execute_lens); + + let profile_command = Command { + title: PROFILE_CODELENS_TITLE.to_string(), + command: PROFILE_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let profile_lens = CodeLens { range, command: Some(profile_command), data: None }; + + lenses.push(profile_lens); + } + } + + if package.is_contract() { + // Currently not looking to deduplicate this since we don't have a clear decision on if the Contract stuff is staying + for contract in context.get_all_contracts(&crate_id) { + let location = contract.location; + let file_id = location.file; + + // Ignore diagnostics for any file that wasn't the file we saved + // TODO: In the future, we could create "related" diagnostics for these files + if let Some(file_path) = file_path { + if fm.path(file_id) != *file_path { + continue; + } + } + + let range = + byte_span_to_range(files, file_id, location.span.into()).unwrap_or_default(); + + let compile_command = Command { + title: with_arrow(COMPILE_CODELENS_TITLE), + command: COMPILE_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let compile_lens = CodeLens { range, command: Some(compile_command), data: None }; + + lenses.push(compile_lens); + + let info_command = Command { + title: INFO_CODELENS_TITLE.to_string(), + command: INFO_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let info_lens = CodeLens { range, command: Some(info_command), data: None }; + + lenses.push(info_lens); + + let profile_command = Command { + title: PROFILE_CODELENS_TITLE.to_string(), + command: PROFILE_COMMAND.into(), + arguments: Some(package_selection_args(workspace, package)), + }; + + let profile_lens = CodeLens { range, command: Some(profile_command), data: None }; + + lenses.push(profile_lens); + } + } + + lenses +} diff --git a/tooling/lsp/src/requests/mod.rs b/tooling/lsp/src/requests/mod.rs index e2fdcdf08da..2711c597bcf 100644 --- a/tooling/lsp/src/requests/mod.rs +++ b/tooling/lsp/src/requests/mod.rs @@ -1,9 +1,10 @@ use std::future::Future; -use crate::types::InitializeParams; +use crate::types::{CodeLensOptions, InitializeParams}; use async_lsp::ResponseError; use lsp_types::{Position, TextDocumentSyncCapability, TextDocumentSyncKind}; use nargo_fmt::Config; +use serde::{Deserialize, Serialize}; use crate::{ types::{InitializeResult, NargoCapability, NargoTestsOptions, ServerCapabilities}, @@ -20,25 +21,58 @@ use crate::{ // They are not attached to the `NargoLspService` struct so they can be unit tested with only `LspState` // and params passed in. +mod code_lens_request; mod goto_definition; mod profile_run; mod test_run; mod tests; pub(crate) use { + code_lens_request::collect_lenses_for_package, code_lens_request::on_code_lens_request, goto_definition::on_goto_definition_request, profile_run::on_profile_run_request, test_run::on_test_run_request, tests::on_tests_request, }; +/// LSP client will send initialization request after the server has started. +/// [InitializeParams].`initialization_options` will contain the options sent from the client. +#[derive(Debug, Deserialize, Serialize)] +struct LspInitializationOptions { + /// Controls whether code lens is enabled by the server + /// By default this will be set to true (enabled). + #[serde(rename = "enableCodeLens", default = "default_enable_code_lens")] + enable_code_lens: bool, +} + +fn default_enable_code_lens() -> bool { + true +} + +impl Default for LspInitializationOptions { + fn default() -> Self { + Self { enable_code_lens: default_enable_code_lens() } + } +} + pub(crate) fn on_initialize( state: &mut LspState, params: InitializeParams, ) -> impl Future> { state.root_path = params.root_uri.and_then(|root_uri| root_uri.to_file_path().ok()); - async { + let initialization_options: LspInitializationOptions = params + .initialization_options + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default(); + + async move { let text_document_sync = TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL); + let code_lens = if initialization_options.enable_code_lens { + Some(CodeLensOptions { resolve_provider: Some(false) }) + } else { + None + }; + let nargo = NargoCapability { tests: Some(NargoTestsOptions { fetch: Some(true), @@ -50,6 +84,7 @@ pub(crate) fn on_initialize( Ok(InitializeResult { capabilities: ServerCapabilities { text_document_sync: Some(text_document_sync), + code_lens_provider: code_lens, document_formatting_provider: true, nargo: Some(nargo), definition_provider: Some(lsp_types::OneOf::Left(true)), @@ -105,7 +140,9 @@ pub(crate) fn on_shutdown( #[cfg(test)] mod initialization { use async_lsp::ClientSocket; - use lsp_types::{InitializeParams, TextDocumentSyncCapability, TextDocumentSyncKind}; + use lsp_types::{ + CodeLensOptions, InitializeParams, TextDocumentSyncCapability, TextDocumentSyncKind, + }; use tokio::test; use crate::{ @@ -125,6 +162,7 @@ mod initialization { text_document_sync: Some(TextDocumentSyncCapability::Kind( TextDocumentSyncKind::FULL )), + code_lens_provider: Some(CodeLensOptions { resolve_provider: Some(false) }), document_formatting_provider: true, .. } diff --git a/tooling/lsp/src/types.rs b/tooling/lsp/src/types.rs index 48c412eb5ad..b2960964e7c 100644 --- a/tooling/lsp/src/types.rs +++ b/tooling/lsp/src/types.rs @@ -9,10 +9,10 @@ use std::collections::{BTreeMap, HashMap}; // Re-providing lsp_types that we don't need to override pub(crate) use lsp_types::{ - Diagnostic, DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, - InitializeParams, InitializedParams, LogMessageParams, MessageType, Position, - PublishDiagnosticsParams, Range, ServerInfo, TextDocumentSyncCapability, Url, + CodeLens, CodeLensOptions, CodeLensParams, Command, Diagnostic, DiagnosticSeverity, + DidChangeConfigurationParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, + DidOpenTextDocumentParams, DidSaveTextDocumentParams, InitializeParams, InitializedParams, + Position, PublishDiagnosticsParams, Range, ServerInfo, TextDocumentSyncCapability, Url, }; pub(crate) mod request { @@ -24,7 +24,9 @@ pub(crate) mod request { }; // Re-providing lsp_types that we don't need to override - pub(crate) use lsp_types::request::{Formatting, GotoDefinition, Shutdown}; + pub(crate) use lsp_types::request::{ + CodeLensRequest as CodeLens, Formatting, GotoDefinition, Shutdown, + }; #[derive(Debug)] pub(crate) struct Initialize; @@ -112,6 +114,10 @@ pub(crate) struct ServerCapabilities { #[serde(skip_serializing_if = "Option::is_none")] pub(crate) definition_provider: Option>, + /// The server provides code lens. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) code_lens_provider: Option, + /// The server provides document formatting. pub(crate) document_formatting_provider: bool, @@ -214,4 +220,5 @@ pub(crate) struct NargoProfileRunResult { pub(crate) opcodes_counts: HashMap, } +pub(crate) type CodeLensResult = Option>; pub(crate) type GotoDefinitionResult = Option;