From 3d16d5fe215ddcdc65476979ce7e8cbbcc6a32fd Mon Sep 17 00:00:00 2001 From: harehare Date: Tue, 14 Jul 2026 20:01:31 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(mq-lsp):=20add=20workspace/sym?= =?UTF-8?q?bol=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the workspace/symbol LSP handler so symbol search works across all files/modules loaded into the shared Hir, not just the current document. Also narrow the existing #[allow(deprecated)] in document_symbol.rs to the single DocumentSymbol.deprecated field assignment kept for pre-3.15 LSP client compatibility. --- crates/mq-lsp/README.md | 1 + crates/mq-lsp/src/capabilities.rs | 1 + crates/mq-lsp/src/document_symbol.rs | 53 +++++++----- crates/mq-lsp/src/lib.rs | 1 + crates/mq-lsp/src/main.rs | 1 + crates/mq-lsp/src/server.rs | 89 ++++++++++++++++++- crates/mq-lsp/src/workspace_symbol.rs | 119 ++++++++++++++++++++++++++ 7 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 crates/mq-lsp/src/workspace_symbol.rs diff --git a/crates/mq-lsp/README.md b/crates/mq-lsp/README.md index 5b75f77b1..b1c045a4c 100644 --- a/crates/mq-lsp/README.md +++ b/crates/mq-lsp/README.md @@ -11,6 +11,7 @@ Language Server Protocol (LSP) implementation for the [mq](https://mqlang.org/) - 🎯 **Go To Definition**: Navigate to symbol definitions with a single click - 🔗 **Find References**: Locate all usages of a symbol across your workspace - 🗂️ **Document Symbols**: Outline view of all symbols in the current file +- 🗃️ **Workspace Symbols**: Search for symbols by name across all loaded files/modules - 🎨 **Semantic Tokens**: Enhanced syntax highlighting based on semantic analysis - ✨ **Code Formatting**: Automatic code formatting following mq style guidelines - 🛠️ **Code Actions**: Quick fixes such as adding a missing `include`/`import` for an unresolved function or module reference diff --git a/crates/mq-lsp/src/capabilities.rs b/crates/mq-lsp/src/capabilities.rs index 9c01c32f0..9b208b03a 100644 --- a/crates/mq-lsp/src/capabilities.rs +++ b/crates/mq-lsp/src/capabilities.rs @@ -35,6 +35,7 @@ pub(crate) fn server_capabilities() -> ServerCapabilities { }, })), document_symbol_provider: Some(OneOf::Left(true)), + workspace_symbol_provider: Some(OneOf::Left(true)), definition_provider: Some(OneOf::Left(true)), references_provider: Some(OneOf::Left(true)), code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions { diff --git a/crates/mq-lsp/src/document_symbol.rs b/crates/mq-lsp/src/document_symbol.rs index 779016ee3..3be07d00f 100644 --- a/crates/mq-lsp/src/document_symbol.rs +++ b/crates/mq-lsp/src/document_symbol.rs @@ -4,7 +4,6 @@ use bimap::BiMap; use tower_lsp_server::ls_types::{DocumentSymbol, DocumentSymbolResponse, Position, Range, SymbolKind, SymbolTag}; use url::Url; -#[allow(deprecated)] pub(crate) fn response( hir: Arc>, url: Url, @@ -32,7 +31,31 @@ pub(crate) fn response( None } else { let is_deprecated = symbol.is_deprecated(); - Some(DocumentSymbol { + let range = Range { + start: Position { + line: text_range.start.line - 1, + character: (text_range.start.column - 1) as u32, + }, + end: Position { + line: text_range.end.line - 1, + character: (text_range.end.column - 1) as u32, + }, + }; + let selection_range = Range { + start: Position { + line: text_range.start.line - 1, + character: (text_range.start.column - 1) as u32, + }, + end: Position { + line: text_range.start.line - 1, + character: (text_range.start.column - 1) as u32, + }, + }; + + // `deprecated` is superseded by `tags`, but still set for + // clients that predate tag support (LSP < 3.15). + #[allow(deprecated)] + let symbol = DocumentSymbol { name: name.to_string(), detail: None, kind, @@ -41,29 +64,13 @@ pub(crate) fn response( } else { None }, - range: Range { - start: Position { - line: text_range.start.line - 1, - character: (text_range.start.column - 1) as u32, - }, - end: Position { - line: text_range.end.line - 1, - character: (text_range.end.column - 1) as u32, - }, - }, - selection_range: Range { - start: Position { - line: text_range.start.line - 1, - character: (text_range.start.column - 1) as u32, - }, - end: Position { - line: text_range.start.line - 1, - character: (text_range.start.column - 1) as u32, - }, - }, + range, + selection_range, children: None, deprecated: Some(is_deprecated), - }) + }; + + Some(symbol) } }) }) diff --git a/crates/mq-lsp/src/lib.rs b/crates/mq-lsp/src/lib.rs index 681950ed6..29b0c5583 100644 --- a/crates/mq-lsp/src/lib.rs +++ b/crates/mq-lsp/src/lib.rs @@ -31,5 +31,6 @@ pub mod references; pub mod rename; pub mod semantic_tokens; pub mod server; +pub mod workspace_symbol; pub use server::start; diff --git a/crates/mq-lsp/src/main.rs b/crates/mq-lsp/src/main.rs index 2b1d28857..b9af6332a 100644 --- a/crates/mq-lsp/src/main.rs +++ b/crates/mq-lsp/src/main.rs @@ -17,6 +17,7 @@ pub mod references; pub mod rename; pub mod semantic_tokens; pub mod server; +pub mod workspace_symbol; #[derive(Parser, Debug)] #[command(name = "mq-lsp")] diff --git a/crates/mq-lsp/src/server.rs b/crates/mq-lsp/src/server.rs index 12f1ccae6..dd97e697a 100644 --- a/crates/mq-lsp/src/server.rs +++ b/crates/mq-lsp/src/server.rs @@ -11,7 +11,7 @@ use url::Url; use crate::error::LspError; use crate::{ capabilities, code_action, completions, document_symbol, execute_command, goto_definition, hover, inlay_hints, - references, rename, semantic_tokens, + references, rename, semantic_tokens, workspace_symbol, }; use tower_lsp_server::{Client, LanguageServer, LspService, Server, jsonrpc, ls_types}; @@ -147,6 +147,18 @@ impl LanguageServer for Backend { )) } + async fn symbol( + &self, + params: ls_types::WorkspaceSymbolParams, + ) -> jsonrpc::Result> { + let source_map_guard = self.source_map.read().unwrap(); + Ok(workspace_symbol::response( + Arc::clone(&self.hir), + ¶ms.query, + &source_map_guard, + )) + } + async fn hover(&self, params: ls_types::HoverParams) -> jsonrpc::Result> { let url = params.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; @@ -1200,6 +1212,79 @@ mod tests { } } + #[tokio::test] + async fn test_workspace_symbol() { + let (service, _) = LspService::new(|client| Backend { + client, + hir: Arc::new(RwLock::new(mq_hir::Hir::default())), + source_map: RwLock::new(BiMap::new()), + type_env_map: DashMap::new(), + error_map: DashMap::new(), + text_map: DashMap::new(), + config: LspConfig::default(), + }); + + let backend = service.inner(); + + let uri_a = Url::parse("file:///a.mq").unwrap(); + let code_a = "def test_func(): 1;"; + let (nodes_a, _) = mq_lang::parse_recovery(code_a); + let (source_id_a, _) = backend.hir.write().unwrap().add_nodes(uri_a.clone(), &nodes_a); + backend + .source_map + .write() + .unwrap() + .insert(uri_a.to_string(), source_id_a); + + let uri_b = Url::parse("file:///b.mq").unwrap(); + let code_b = "def other_func(): 2;"; + let (nodes_b, _) = mq_lang::parse_recovery(code_b); + let (source_id_b, _) = backend.hir.write().unwrap().add_nodes(uri_b.clone(), &nodes_b); + backend + .source_map + .write() + .unwrap() + .insert(uri_b.to_string(), source_id_b); + + let result = backend + .symbol(ls_types::WorkspaceSymbolParams { + query: "test_func".to_string(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .await; + + assert!(result.is_ok()); + let symbols = result.unwrap().unwrap(); + + if let ls_types::WorkspaceSymbolResponse::Nested(symbols) = symbols { + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "test_func"); + } else { + panic!("Expected nested workspace symbol response"); + } + + let result = backend + .symbol(ls_types::WorkspaceSymbolParams { + query: "func".to_string(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .await; + + assert!(result.is_ok()); + let symbols = result.unwrap().unwrap(); + + if let ls_types::WorkspaceSymbolResponse::Nested(symbols) = symbols { + let symbol_names: Vec = symbols.iter().map(|s| s.name.clone()).collect(); + assert!(symbol_names.contains(&"test_func".to_string())); + assert!(symbol_names.contains(&"other_func".to_string())); + assert_eq!(symbols.len(), 2); + } else { + panic!("Expected nested workspace symbol response"); + } + } + #[tokio::test] async fn test_did_change() { let (service, _) = LspService::new(|client| Backend { @@ -1362,6 +1447,8 @@ mod tests { assert!(capabilities.completion_provider.is_some()); assert!(capabilities.code_action_provider.is_some()); assert!(capabilities.rename_provider.is_some()); + assert!(capabilities.document_symbol_provider.is_some()); + assert!(capabilities.workspace_symbol_provider.is_some()); // Test shutdown let shutdown_result = backend.shutdown().await; diff --git a/crates/mq-lsp/src/workspace_symbol.rs b/crates/mq-lsp/src/workspace_symbol.rs new file mode 100644 index 000000000..ec264c805 --- /dev/null +++ b/crates/mq-lsp/src/workspace_symbol.rs @@ -0,0 +1,119 @@ +use std::str::FromStr; +use std::sync::{Arc, RwLock}; + +use bimap::BiMap; +use tower_lsp_server::ls_types::{ + self, Location, OneOf, Position, Range, SymbolKind, SymbolTag, WorkspaceSymbol, WorkspaceSymbolResponse, +}; + +pub(crate) fn response( + hir: Arc>, + query: &str, + source_map: &BiMap, +) -> Option { + let hir_guard = hir.read().unwrap(); + let query = query.to_lowercase(); + + let symbols = hir_guard + .symbols() + .filter_map(|(_, symbol)| { + let name = symbol.value.as_ref()?; + if name.is_empty() || (!query.is_empty() && !name.to_lowercase().contains(&query)) { + return None; + } + + let kind = match &symbol.kind { + mq_hir::SymbolKind::Function(_) | mq_hir::SymbolKind::Macro(_) => SymbolKind::FUNCTION, + mq_hir::SymbolKind::Variable | mq_hir::SymbolKind::DestructuringBinding => SymbolKind::FIELD, + mq_hir::SymbolKind::String => SymbolKind::STRING, + mq_hir::SymbolKind::Boolean => SymbolKind::BOOLEAN, + mq_hir::SymbolKind::None => SymbolKind::NULL, + _ => return None, + }; + + let text_range = symbol.source.text_range?; + let source_id = symbol.source.source_id?; + let url = source_map.get_by_right(&source_id)?; + let is_deprecated = symbol.is_deprecated(); + + Some(WorkspaceSymbol { + name: name.to_string(), + kind, + tags: if is_deprecated { + Some(vec![SymbolTag::DEPRECATED]) + } else { + None + }, + container_name: None, + location: OneOf::Left(Location { + uri: ls_types::Uri::from_str(url).unwrap(), + range: Range { + start: Position { + line: text_range.start.line - 1, + character: (text_range.start.column - 1) as u32, + }, + end: Position { + line: text_range.end.line - 1, + character: (text_range.end.column - 1) as u32, + }, + }, + }), + data: None, + }) + }) + .collect::>(); + + Some(WorkspaceSymbolResponse::Nested(symbols)) +} + +#[cfg(test)] +mod tests { + use super::*; + use url::Url; + + #[test] + fn test_response_with_empty_hir() { + let hir = Arc::new(RwLock::new(mq_hir::Hir::default())); + let source_map = BiMap::new(); + let res = response(hir, "", &source_map); + + assert!(matches!(res, Some(WorkspaceSymbolResponse::Nested(symbols)) if symbols.is_empty())); + } + + #[test] + fn test_response_matches_across_multiple_sources() { + let mut hir = mq_hir::Hir::default(); + let url1 = Url::parse("file:///a.mq").unwrap(); + let url2 = Url::parse("file:///b.mq").unwrap(); + + let (source_id1, _) = hir.add_code(Some(url1.clone()), "def func_a(): 1;"); + let (source_id2, _) = hir.add_code(Some(url2.clone()), "def func_b(): 2; | let var_b = 3"); + + let mut source_map = BiMap::new(); + source_map.insert(url1.to_string(), source_id1); + source_map.insert(url2.to_string(), source_id2); + + let hir = Arc::new(RwLock::new(hir)); + + let res = response(hir.clone(), "func", &source_map); + if let Some(WorkspaceSymbolResponse::Nested(symbols)) = res { + assert_eq!(symbols.len(), 2); + assert!(symbols.iter().any(|s| s.name == "func_a")); + assert!(symbols.iter().any(|s| s.name == "func_b")); + assert!(symbols.iter().all(|s| s.kind == SymbolKind::FUNCTION)); + } else { + panic!("Expected Nested response"); + } + + let res = response(hir.clone(), "var_b", &source_map); + if let Some(WorkspaceSymbolResponse::Nested(symbols)) = res { + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "var_b"); + } else { + panic!("Expected Nested response"); + } + + let res = response(hir, "does_not_exist", &source_map); + assert!(matches!(res, Some(WorkspaceSymbolResponse::Nested(symbols)) if symbols.is_empty())); + } +}