diff --git a/Cargo.lock b/Cargo.lock index e95f7831278..120176cfd49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1916,6 +1916,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -3364,6 +3373,7 @@ dependencies = [ "codespan-lsp", "convert_case", "fm", + "fuzzy-matcher", "fxhash", "iter-extended", "lsp-types 0.94.1", diff --git a/tooling/lsp/Cargo.toml b/tooling/lsp/Cargo.toml index d0b67f53c24..77f2b427867 100644 --- a/tooling/lsp/Cargo.toml +++ b/tooling/lsp/Cargo.toml @@ -34,6 +34,7 @@ fxhash.workspace = true iter-extended.workspace = true convert_case = "0.6.0" num-bigint.workspace = true +fuzzy-matcher = "0.3.7" [target.'cfg(all(target_arch = "wasm32", not(target_os = "wasi")))'.dependencies] wasm-bindgen.workspace = true diff --git a/tooling/lsp/src/lib.rs b/tooling/lsp/src/lib.rs index 784ac8cf93d..a73dbaa339d 100644 --- a/tooling/lsp/src/lib.rs +++ b/tooling/lsp/src/lib.rs @@ -24,7 +24,7 @@ use lsp_types::{ CodeLens, request::{ CodeActionRequest, Completion, DocumentSymbolRequest, HoverRequest, InlayHintRequest, - PrepareRenameRequest, References, Rename, SignatureHelpRequest, + PrepareRenameRequest, References, Rename, SignatureHelpRequest, WorkspaceSymbolRequest, }, }; use nargo::{ @@ -52,11 +52,12 @@ use notifications::{ on_did_open_text_document, on_did_save_text_document, on_exit, on_initialized, }; use requests::{ - LspInitializationOptions, on_code_action_request, on_code_lens_request, on_completion_request, - on_document_symbol_request, on_formatting, on_goto_declaration_request, + LspInitializationOptions, WorkspaceSymbolCache, on_code_action_request, on_code_lens_request, + on_completion_request, on_document_symbol_request, on_formatting, on_goto_declaration_request, on_goto_definition_request, on_goto_type_definition_request, on_hover_request, on_initialize, on_inlay_hint_request, on_prepare_rename_request, on_references_request, on_rename_request, on_shutdown, on_signature_help_request, on_test_run_request, on_tests_request, + on_workspace_symbol_request, }; use serde_json::Value as JsonValue; use thiserror::Error; @@ -100,6 +101,7 @@ pub struct LspState { cached_parsed_files: HashMap))>, workspace_cache: HashMap, package_cache: HashMap, + workspace_symbol_cache: WorkspaceSymbolCache, options: LspInitializationOptions, // Tracks files that currently have errors, by package root. @@ -132,6 +134,7 @@ impl LspState { cached_parsed_files: HashMap::new(), workspace_cache: HashMap::new(), package_cache: HashMap::new(), + workspace_symbol_cache: WorkspaceSymbolCache::default(), open_documents_count: 0, options: Default::default(), files_with_errors: HashMap::new(), @@ -169,6 +172,7 @@ impl NargoLspService { .request::(on_completion_request) .request::(on_signature_help_request) .request::(on_code_action_request) + .request::(on_workspace_symbol_request) .notification::(on_initialized) .notification::(on_did_change_configuration) .notification::(on_did_open_text_document) @@ -429,13 +433,7 @@ pub fn insert_all_files_for_workspace_into_file_manager( workspace: &Workspace, file_manager: &mut FileManager, ) { - // Source code for files we cached override those that are read from disk. - let mut overrides: HashMap<&Path, &str> = HashMap::new(); - for (path, source) in &state.input_files { - let path = path.strip_prefix("file://").unwrap(); - overrides.insert(Path::new(path), source); - } - + let overrides = source_code_overrides(&state.input_files); nargo::insert_all_files_for_workspace_into_file_manager_with_overrides( workspace, file_manager, @@ -443,6 +441,16 @@ pub fn insert_all_files_for_workspace_into_file_manager( ); } +// Source code for files we cached override those that are read from disk. +pub fn source_code_overrides(input_files: &HashMap) -> HashMap { + let mut overrides: HashMap = HashMap::new(); + for (path, source) in input_files { + let path = path.strip_prefix("file://").unwrap(); + overrides.insert(PathBuf::from_str(path).unwrap(), source); + } + overrides +} + #[test] fn prepare_package_from_source_string() { let source = r#" diff --git a/tooling/lsp/src/notifications/mod.rs b/tooling/lsp/src/notifications/mod.rs index b7ba8cd4761..2a9f3ef0d00 100644 --- a/tooling/lsp/src/notifications/mod.rs +++ b/tooling/lsp/src/notifications/mod.rs @@ -62,6 +62,7 @@ pub(super) fn on_did_change_text_document( ) -> ControlFlow> { let text = params.content_changes.into_iter().next().unwrap().text; state.input_files.insert(params.text_document.uri.to_string(), text.clone()); + state.workspace_symbol_cache.reprocess_uri(¶ms.text_document.uri); let document_uri = params.text_document.uri; let output_diagnostics = false; @@ -78,6 +79,7 @@ pub(super) fn on_did_close_text_document( ) -> ControlFlow> { state.input_files.remove(¶ms.text_document.uri.to_string()); state.cached_lenses.remove(¶ms.text_document.uri.to_string()); + state.workspace_symbol_cache.reprocess_uri(¶ms.text_document.uri); state.open_documents_count -= 1; diff --git a/tooling/lsp/src/requests/mod.rs b/tooling/lsp/src/requests/mod.rs index a5ffd1155fa..852d2cc8edb 100644 --- a/tooling/lsp/src/requests/mod.rs +++ b/tooling/lsp/src/requests/mod.rs @@ -30,6 +30,8 @@ use crate::{ types::{InitializeResult, NargoCapability, NargoTestsOptions, ServerCapabilities}, }; +pub(crate) use workspace_symbol::WorkspaceSymbolCache; + // Handlers // The handlers for `request` are not `async` because it compiles down to lifetimes that can't be added to // the router. To return a future that fits the trait, it is easiest wrap your implementations in an `async {}` @@ -53,6 +55,7 @@ mod rename; mod signature_help; mod test_run; mod tests; +mod workspace_symbol; pub(crate) use { code_action::on_code_action_request, code_lens_request::collect_lenses_for_package, @@ -62,7 +65,7 @@ pub(crate) use { hover::on_hover_request, inlay_hint::on_inlay_hint_request, references::on_references_request, rename::on_prepare_rename_request, rename::on_rename_request, signature_help::on_signature_help_request, test_run::on_test_run_request, - tests::on_tests_request, + tests::on_tests_request, workspace_symbol::on_workspace_symbol_request, }; /// LSP client will send initialization request after the server has started. @@ -285,6 +288,14 @@ pub(crate) fn on_initialize( }, resolve_provider: None, })), + workspace_symbol_provider: Some(lsp_types::OneOf::Right( + lsp_types::WorkspaceSymbolOptions { + work_done_progress_options: WorkDoneProgressOptions { + work_done_progress: None, + }, + resolve_provider: None, + }, + )), }, server_info: None, }) diff --git a/tooling/lsp/src/requests/workspace_symbol.rs b/tooling/lsp/src/requests/workspace_symbol.rs new file mode 100644 index 00000000000..1402ffae233 --- /dev/null +++ b/tooling/lsp/src/requests/workspace_symbol.rs @@ -0,0 +1,299 @@ +use std::{ + collections::{HashMap, HashSet}, + future::{self, Future}, + path::PathBuf, +}; + +use async_lsp::ResponseError; +use fm::{FileManager, FileMap}; +use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; +use lsp_types::{SymbolKind, Url, WorkspaceSymbol, WorkspaceSymbolParams, WorkspaceSymbolResponse}; +use nargo::{insert_all_files_under_path_into_file_manager, parse_all}; +use noirc_errors::{Location, Span}; +use noirc_frontend::{ + ast::{ + Ident, LetStatement, NoirEnumeration, NoirFunction, NoirStruct, NoirTrait, NoirTraitImpl, + NoirTypeAlias, Pattern, TraitImplItemKind, TraitItem, TypeImpl, Visitor, + }, + parser::ParsedSubModule, +}; + +use crate::{LspState, source_code_overrides}; + +use super::to_lsp_location; + +pub(crate) fn on_workspace_symbol_request( + state: &mut LspState, + params: WorkspaceSymbolParams, +) -> impl Future, ResponseError>> + use<> { + let Some(root_path) = state.root_path.clone() else { + return future::ready(Ok(None)); + }; + + let overrides = source_code_overrides(&state.input_files); + + // Prepare a FileManager for files we'll parse, to extract symbols from + let mut file_manager = FileManager::new(root_path.as_path()); + + let cache = &mut state.workspace_symbol_cache; + + // If the cache is not initialized yet, put all files in the workspace in the FileManager + if !cache.initialized { + insert_all_files_under_path_into_file_manager(&mut file_manager, &root_path, &overrides); + cache.initialized = true; + } + + // Then add files that we need to re-process + for path in std::mem::take(&mut cache.paths_to_reprocess) { + if !path.exists() { + continue; + } + + if let Some(source) = overrides.get(path.as_path()) { + file_manager.add_file_with_source(path.as_path(), source.to_string()); + } else { + let source = std::fs::read_to_string(path.as_path()) + .unwrap_or_else(|_| panic!("could not read file {:?} into string", path)); + file_manager.add_file_with_source(path.as_path(), source); + } + } + + // Note: what happens if a file is deleted? We don't get notifications when a file is deleted + // so we might return symbols for a file that doesn't exist. However, VSCode seems to notice + // the file doesn't exist and simply doesn't show the symbol. What if the file is re-created? + // In that case we do get a notification so we'll reprocess that file. + + // Parse all files for which we don't know their symbols yet, + // figure out the symbols and store them in the cache. + let parsed_files = parse_all(&file_manager); + for (file_id, (parsed_module, _)) in parsed_files { + let path = file_manager.path(file_id).unwrap().to_path_buf(); + let mut gatherer = WorkspaceSymbolGatherer::new(file_manager.as_file_map()); + parsed_module.accept(&mut gatherer); + cache.symbols_per_path.insert(path, gatherer.symbols); + } + + // Finally, we filter the symbols based on the query + // (Note: we could filter them as we gather them above, but doing this in one go is simpler) + let matcher = SkimMatcherV2::default(); + let symbols = cache + .symbols_per_path + .values() + .flat_map(|symbols| { + symbols.iter().filter_map(|symbol| { + if matcher.fuzzy_match(&symbol.name, ¶ms.query).is_some() { + Some(symbol.clone()) + } else { + None + } + }) + }) + .collect::>(); + + future::ready(Ok(Some(WorkspaceSymbolResponse::Nested(symbols)))) +} + +#[derive(Default)] +pub(crate) struct WorkspaceSymbolCache { + initialized: bool, + symbols_per_path: HashMap>, + /// Whenever a file changes we'll add it to this set. Then, when workspace symbols + /// are requested we'll reprocess these files in search for symbols. + paths_to_reprocess: HashSet, +} + +impl WorkspaceSymbolCache { + pub(crate) fn reprocess_uri(&mut self, uri: &Url) { + if !self.initialized { + return; + } + + if let Ok(path) = uri.to_file_path() { + self.symbols_per_path.remove(&path); + self.paths_to_reprocess.insert(path.clone()); + } + } +} + +struct WorkspaceSymbolGatherer<'files> { + symbols: Vec, + files: &'files FileMap, +} + +impl<'files> WorkspaceSymbolGatherer<'files> { + fn new(files: &'files FileMap) -> Self { + Self { symbols: Vec::new(), files } + } + + fn to_lsp_location(&self, location: Location) -> Option { + to_lsp_location(self.files, location.file, location.span) + } + + fn push_symbol(&mut self, name: &Ident, kind: SymbolKind) { + self.push_symbol_impl(name, kind, None); + } + + fn push_contained_symbol(&mut self, name: &Ident, kind: SymbolKind, container_name: String) { + self.push_symbol_impl(name, kind, Some(container_name)); + } + + fn push_symbol_impl(&mut self, name: &Ident, kind: SymbolKind, container_name: Option) { + let Some(location) = self.to_lsp_location(name.location()) else { + return; + }; + + let name = name.to_string(); + let location = lsp_types::OneOf::Left(location); + let symbol = + WorkspaceSymbol { name, kind, tags: None, container_name, location, data: None }; + self.symbols.push(symbol); + } +} + +impl Visitor for WorkspaceSymbolGatherer<'_> { + fn visit_parsed_submodule(&mut self, submodule: &ParsedSubModule, _: Span) -> bool { + self.push_symbol(&submodule.name, SymbolKind::MODULE); + true + } + + fn visit_noir_function(&mut self, noir_function: &NoirFunction, _span: Span) -> bool { + self.push_symbol(noir_function.name_ident(), SymbolKind::FUNCTION); + false + } + + fn visit_noir_struct(&mut self, noir_struct: &NoirStruct, _span: Span) -> bool { + self.push_symbol(&noir_struct.name, SymbolKind::STRUCT); + false + } + + fn visit_noir_enum(&mut self, noir_enum: &NoirEnumeration, _span: Span) -> bool { + self.push_symbol(&noir_enum.name, SymbolKind::ENUM); + false + } + + fn visit_noir_type_alias(&mut self, alias: &NoirTypeAlias, _span: Span) -> bool { + self.push_symbol(&alias.name, SymbolKind::STRUCT); + false + } + + fn visit_noir_trait(&mut self, noir_trait: &NoirTrait, _: Span) -> bool { + self.push_symbol(&noir_trait.name, SymbolKind::INTERFACE); + + let container_name = noir_trait.name.to_string(); + + for item in &noir_trait.items { + match &item.item { + TraitItem::Function { name, .. } => { + self.push_contained_symbol(name, SymbolKind::FUNCTION, container_name.clone()); + } + TraitItem::Constant { .. } | TraitItem::Type { .. } => (), + } + } + + false + } + + fn visit_type_impl(&mut self, type_impl: &TypeImpl, _: Span) -> bool { + let container_name = type_impl.object_type.to_string(); + + for (method, _location) in &type_impl.methods { + let method = &method.item; + let kind = SymbolKind::FUNCTION; + self.push_contained_symbol(method.name_ident(), kind, container_name.clone()); + } + + false + } + + fn visit_noir_trait_impl(&mut self, trait_impl: &NoirTraitImpl, _: Span) -> bool { + let container_name = trait_impl.object_type.to_string(); + + for item in &trait_impl.items { + match &item.item.kind { + TraitImplItemKind::Function(noir_function) => { + let name = noir_function.name_ident(); + let kind = SymbolKind::FUNCTION; + self.push_contained_symbol(name, kind, container_name.clone()); + } + TraitImplItemKind::Constant(..) | TraitImplItemKind::Type { .. } => (), + } + } + + false + } + + fn visit_global(&mut self, global: &LetStatement, _span: Span) -> bool { + let Pattern::Identifier(name) = &global.pattern else { + return false; + }; + + self.push_symbol(name, SymbolKind::CONSTANT); + false + } +} + +#[cfg(test)] +mod tests { + use lsp_types::{ + PartialResultParams, SymbolKind, WorkDoneProgressParams, WorkspaceSymbolParams, + WorkspaceSymbolResponse, + }; + use tokio::test; + + use crate::{on_workspace_symbol_request, test_utils}; + + #[test] + async fn test_workspace_symbol() { + let (mut state, _) = test_utils::init_lsp_server("document_symbol").await; + + let response = on_workspace_symbol_request( + &mut state, + WorkspaceSymbolParams { + query: String::new(), + partial_result_params: PartialResultParams::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + }, + ) + .await + .expect("Could not execute on_document_symbol_request") + .unwrap(); + + let WorkspaceSymbolResponse::Nested(symbols) = response else { + panic!("Expected Nested response, got {:?}", response); + }; + + assert_eq!(symbols.len(), 8); + + assert_eq!(&symbols[0].name, "foo"); + assert_eq!(symbols[0].kind, SymbolKind::FUNCTION); + assert!(symbols[0].container_name.is_none()); + + assert_eq!(&symbols[1].name, "SomeStruct"); + assert_eq!(symbols[1].kind, SymbolKind::STRUCT); + assert!(symbols[1].container_name.is_none()); + + assert_eq!(&symbols[2].name, "new"); + assert_eq!(symbols[2].kind, SymbolKind::FUNCTION); + assert_eq!(symbols[2].container_name.as_ref().unwrap(), "SomeStruct"); + + assert_eq!(&symbols[3].name, "SomeTrait"); + assert_eq!(symbols[3].kind, SymbolKind::INTERFACE); + assert!(symbols[3].container_name.is_none()); + + assert_eq!(&symbols[4].name, "some_method"); + assert_eq!(symbols[4].kind, SymbolKind::FUNCTION); + assert_eq!(symbols[4].container_name.as_ref().unwrap(), "SomeTrait"); + + assert_eq!(&symbols[5].name, "some_method"); + assert_eq!(symbols[5].kind, SymbolKind::FUNCTION); + assert_eq!(symbols[5].container_name.as_ref().unwrap(), "SomeStruct"); + + assert_eq!(&symbols[6].name, "submodule"); + assert_eq!(symbols[6].kind, SymbolKind::MODULE); + assert!(symbols[6].container_name.is_none()); + + assert_eq!(&symbols[7].name, "SOME_GLOBAL"); + assert_eq!(symbols[7].kind, SymbolKind::CONSTANT); + assert!(symbols[7].container_name.is_none()); + } +} diff --git a/tooling/lsp/src/types.rs b/tooling/lsp/src/types.rs index 21d274a3776..f5c51000e8e 100644 --- a/tooling/lsp/src/types.rs +++ b/tooling/lsp/src/types.rs @@ -1,7 +1,7 @@ use lsp_types::{ CodeActionOptions, CompletionOptions, DeclarationCapability, DefinitionOptions, DocumentSymbolOptions, HoverOptions, InlayHintOptions, OneOf, ReferencesOptions, RenameOptions, - SignatureHelpOptions, TypeDefinitionProviderCapability, + SignatureHelpOptions, TypeDefinitionProviderCapability, WorkspaceSymbolOptions, }; use noirc_frontend::graph::CrateName; use serde::{Deserialize, Serialize}; @@ -156,6 +156,10 @@ pub(crate) struct ServerCapabilities { /// The server provides code action support. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) code_action_provider: Option>, + + /// The server provides workspace symbol support. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) workspace_symbol_provider: Option>, } #[derive(Debug, PartialEq, Clone, Default, Deserialize, Serialize)] diff --git a/tooling/nargo/src/lib.rs b/tooling/nargo/src/lib.rs index e384f4ce531..e57202e39fd 100644 --- a/tooling/nargo/src/lib.rs +++ b/tooling/nargo/src/lib.rs @@ -63,7 +63,7 @@ pub fn insert_all_files_for_workspace_into_file_manager( pub fn insert_all_files_for_workspace_into_file_manager_with_overrides( workspace: &workspace::Workspace, file_manager: &mut FileManager, - overrides: &HashMap<&std::path::Path, &str>, + overrides: &HashMap, ) { let mut processed_entry_paths = HashSet::new(); for package in workspace.clone().into_iter() { @@ -84,7 +84,7 @@ pub fn insert_all_files_for_workspace_into_file_manager_with_overrides( fn insert_all_files_for_package_into_file_manager( package: &Package, file_manager: &mut FileManager, - overrides: &HashMap<&std::path::Path, &str>, + overrides: &HashMap, processed_entry_paths: &mut HashSet, ) { if processed_entry_paths.contains(&package.entry_path) { @@ -98,35 +98,7 @@ fn insert_all_files_for_package_into_file_manager( .parent() .unwrap_or_else(|| panic!("The entry path is expected to be a single file within a directory and so should have a parent {:?}", package.entry_path)); - for entry in WalkDir::new(entry_path_parent).sort_by_file_name() { - let Ok(entry) = entry else { - continue; - }; - - if !entry.file_type().is_file() { - continue; - } - - if entry.path().extension().is_none_or(|ext| ext != FILE_EXTENSION) { - continue; - }; - - let path = entry.into_path(); - - // Avoid reading the source if the file is already there - if file_manager.has_file(&path) { - continue; - } - - let source = if let Some(src) = overrides.get(path.as_path()) { - src.to_string() - } else { - std::fs::read_to_string(path.as_path()) - .unwrap_or_else(|_| panic!("could not read file {:?} into string", path)) - }; - - file_manager.add_file_with_source(path.as_path(), source); - } + insert_all_files_under_path_into_file_manager(file_manager, entry_path_parent, overrides); insert_all_files_for_packages_dependencies_into_file_manager( package, @@ -141,7 +113,7 @@ fn insert_all_files_for_package_into_file_manager( fn insert_all_files_for_packages_dependencies_into_file_manager( package: &Package, file_manager: &mut FileManager, - overrides: &HashMap<&std::path::Path, &str>, + overrides: &HashMap, processed_entry_paths: &mut HashSet, ) { for (_, dep) in package.dependencies.iter() { @@ -158,6 +130,42 @@ fn insert_all_files_for_packages_dependencies_into_file_manager( } } +pub fn insert_all_files_under_path_into_file_manager( + file_manager: &mut FileManager, + path: &std::path::Path, + overrides: &HashMap, +) { + for entry in WalkDir::new(path).sort_by_file_name() { + let Ok(entry) = entry else { + continue; + }; + + if !entry.file_type().is_file() { + continue; + } + + if entry.path().extension().is_none_or(|ext| ext != FILE_EXTENSION) { + continue; + }; + + let path = entry.into_path(); + + // Avoid reading the source if the file is already there + if file_manager.has_file(&path) { + continue; + } + + let source = if let Some(src) = overrides.get(path.as_path()) { + src.to_string() + } else { + std::fs::read_to_string(path.as_path()) + .unwrap_or_else(|_| panic!("could not read file {:?} into string", path)) + }; + + file_manager.add_file_with_source(path.as_path(), source); + } +} + pub fn parse_all(file_manager: &FileManager) -> ParsedFiles { file_manager .as_file_map()