use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use anyhow::Result; use galaxy_util::on_cancel::OnCancelFutureExt; use globset::{Glob, GlobMatcher}; use jsonrpc::{JsonRpcService, RequestId, ServerNotificationEvent}; use lsp_types::notification::{self, Notification}; use lsp_types::request::{self, Request}; use lsp_types::{ CancelParams, CodeActionContext, CodeActionParams, CompletionContext, CompletionItem, CompletionParams, CompletionTriggerKind, DidChangeTextDocumentParams, DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DocumentFormattingParams, FileChangeType, FileSystemWatcher, FormattingOptions, GlobPattern, GotoDefinitionParams, GotoDefinitionResponse, HoverParams, InitializeParams, InitializedParams, NumberOrString, OneOf, Position, Range as LspRange, ReferenceParams, RegistrationParams, RelativePattern, RenameParams, SignatureHelpParams, TextDocumentIdentifier, TextDocumentItem, TextDocumentPositionParams, UnregistrationParams, VersionedTextDocumentIdentifier, WatchKind, }; use serde_json::Value; #[cfg(not(target_arch = "wasm32"))] use simple_logger::SimpleLogger; use crate::config::{lsp_uri_to_path, path_to_lsp_uri, LanguageId}; use crate::types::{ CodeActionData, CompletionResult, FileEdits, HoverResult, Location, LspDefinitionLocation, PrepareRenameResult, Range, ReferenceLocation, RenameResult, SignatureHelpResult, TextDocumentContentChangeEvent, TextEdit, WatchedFileChangeEvent, }; use crate::LspServerLogLevel; /// Tracks the sync state for an open document. #[derive(Debug, Clone)] pub struct DocumentSyncState { /// The last buffer version that was successfully synced with the LSP server. /// None means the document was opened but no subsequent changes have been synced yet. pub last_synced_version: Option, } pub struct LspService { jsonrpc_service: JsonRpcService, server_capabilities: Option, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] open_documents: Arc>>, watched_files_registry: Arc>, notify_tx: async_channel::Sender, #[cfg(not(target_arch = "wasm32"))] logger: Option, } struct LspServerRequestHandler { watched_files_registry: Arc>, } impl LspServerRequestHandler { fn handle_request(&self, method: &str, params: Value, id: RequestId) -> Result<()> { match method { "client/registerCapability" => { let params = match serde_json::from_value::(params) { Ok(params) => params, Err(e) => { log::warn!( "Failed to parse client/registerCapability params (id: {id:?}): {e}" ); return Ok(()); } }; for registration in params.registrations { if registration.method != notification::DidChangeWatchedFiles::METHOD { continue; } let Some(register_options) = registration.register_options else { log::debug!( "Ignoring didChangeWatchedFiles registration without options (id: {})", registration.id ); continue; }; let options = match serde_json::from_value::< DidChangeWatchedFilesRegistrationOptions, >(register_options) { Ok(options) => options, Err(e) => { log::warn!( "Failed to parse didChangeWatchedFiles registration options (id: {}): {e}", registration.id ); continue; } }; let mut registry = self .watched_files_registry .lock() .unwrap_or_else(|e| e.into_inner()); registry.register(registration.id, options); } } "client/unregisterCapability" => { let params = match serde_json::from_value::(params) { Ok(params) => params, Err(e) => { log::warn!( "Failed to parse client/unregisterCapability params (id: {id:?}): {e}" ); return Ok(()); } }; let mut registry = self .watched_files_registry .lock() .unwrap_or_else(|e| e.into_inner()); for unregistration in params.unregisterations { if unregistration.method == notification::DidChangeWatchedFiles::METHOD { registry.unregister(&unregistration.id); } } } _ => {} } Ok(()) } } impl LspService { /// Creates a new LspService with the given JsonRpcService. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn new( jsonrpc_service: JsonRpcService, notify_tx: async_channel::Sender, workspace_root: PathBuf, #[cfg(not(target_arch = "wasm32"))] logger: Option, ) -> Result { let watched_files_registry = Arc::new(Mutex::new(WatchedFilesRegistry::new(workspace_root))); let server_request_handler = Arc::new(LspServerRequestHandler { watched_files_registry: watched_files_registry.clone(), }); let server_request_handler_for_closure = server_request_handler.clone(); jsonrpc_service.set_server_request_handler(move |method, params, id| { server_request_handler_for_closure.handle_request(&method, params, id) }); let service = Self { jsonrpc_service, server_capabilities: None, open_documents: Arc::new(Mutex::new(HashMap::new())), watched_files_registry, notify_tx, #[cfg(not(target_arch = "wasm32"))] logger, }; Ok(service) } #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) async fn initialize(&mut self, params: InitializeParams) -> Result<()> { let response = self.send_request::(params).await?; self.server_capabilities = Some(response.capabilities); self.send_notification::(InitializedParams {})?; self.subscribe::().await; self.subscribe::().await; log::info!("LSP initialized successfully and will now run startup tasks"); Ok(()) } pub(crate) async fn subscribe(&self) { self.jsonrpc_service .subscribe(N::METHOD.to_string(), self.notify_tx.clone()) .await; } pub fn server_capabilities(&self) -> &Option { &self.server_capabilities } pub fn log_to_server_log(&self, level: LspServerLogLevel, message: impl Into) { #[cfg(not(target_arch = "wasm32"))] { if let Some(logger) = &self.logger { logger.log(format!("[{level}] {}", message.into())); } } #[cfg(target_arch = "wasm32")] { let _ = (level, message.into()); } } pub async fn shutdown(&self) -> anyhow::Result<()> { // Send LSP shutdown request first log::debug!("Sending LSP shutdown request"); match self.send_request::(()).await { Ok(_) => log::debug!("LSP shutdown request completed successfully"), Err(e) => { log::warn!("LSP shutdown request failed: {e}"); // Continue with exit notification even if shutdown request fails } } // Send exit notification log::debug!("Sending LSP exit notification"); if let Err(e) = self.send_notification::(()) { log::warn!("Failed to send LSP exit notification: {e}"); } // Finally, shutdown the transport (kill process if needed) self.jsonrpc_service .shutdown(std::time::Duration::from_secs(5)) .await?; log::debug!("LSP shutdown sequence completed"); Ok(()) } /// Get a handle to text-document related operations and requests pub fn text_document(&self) -> TextDocumentService<'_> { TextDocumentService { service: self, open_documents: self.open_documents.clone(), } } pub fn workspace_watched_files_changed( &self, events: Vec, ) -> Result<()> { if events.is_empty() { return Ok(()); } let watched_files_registry = self .watched_files_registry .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on watched files registry"))?; // If the server hasn't registered any watchers yet, don't send notifications. if watched_files_registry.is_empty() { return Ok(()); } let mut changes = Vec::new(); for event in events { if !watched_files_registry.matches(&event) { continue; } match event.into_lsp() { Ok(event) => changes.push(event), Err(e) => log::warn!("Failed to convert file event: {e}"), } } drop(watched_files_registry); if changes.is_empty() { return Ok(()); } self.send_notification::(DidChangeWatchedFilesParams { changes, }) } async fn send_request_internal(&self, method: String, params: Value) -> Result { let request_id = self.jsonrpc_service.next_id(); let request = self .jsonrpc_service .send_request(request_id, method, params); request .on_cancel(move || { self.cancel_request(request_id); }) .await } fn send_notification(&self, params: N::Params) -> Result<()> { let params = serde_json::to_value(params)?; self.jsonrpc_service .send_notification(N::METHOD.to_string(), params) } async fn send_request(&self, params: R::Params) -> Result { let params = serde_json::to_value(params)?; let request = self.send_request_internal(R::METHOD.to_string(), params); let response = request.await?; serde_json::from_value::(response) .map_err(|e| anyhow::anyhow!("Failed to parse response: {e}")) } fn cancel_request(&self, request_id: RequestId) { let cancel = serde_json::to_value(CancelParams { id: NumberOrString::Number(request_id), }) .expect("Failed to serialize cancel params"); if let Err(e) = self .jsonrpc_service .send_notification(notification::Cancel::METHOD.to_string(), cancel) { log::error!("Failed to send cancel notification: {e}"); } } } struct WatchedFilesRegistry { workspace_root: PathBuf, registrations: HashMap>, } impl WatchedFilesRegistry { fn new(workspace_root: PathBuf) -> Self { Self { workspace_root, registrations: HashMap::new(), } } fn is_empty(&self) -> bool { self.registrations.is_empty() } fn register( &mut self, registration_id: String, options: DidChangeWatchedFilesRegistrationOptions, ) { let watchers = options .watchers .into_iter() .filter_map(|watcher| self.compile_watcher(watcher)) .collect(); self.registrations.insert(registration_id, watchers); } fn unregister(&mut self, registration_id: &str) { self.registrations.remove(registration_id); } fn matches(&self, event: &WatchedFileChangeEvent) -> bool { let Ok(path_relative) = event.path.strip_prefix(&self.workspace_root) else { return false; }; let path_relative = galaxy_util::path::normalize_relative_path_for_glob(path_relative); self.registrations .values() .flatten() .any(|watcher| watcher.matches(&path_relative, event.typ)) } fn compile_watcher(&self, watcher: FileSystemWatcher) -> Option { let FileSystemWatcher { glob_pattern, kind } = watcher; let pattern = self.pattern_for_glob_pattern(glob_pattern)?; let matcher = match Glob::new(&pattern) { Ok(glob) => { let matcher: GlobMatcher = glob.compile_matcher(); matcher } Err(e) => { log::warn!("Failed to compile watched-files glob pattern {pattern:?}: {e}"); return None; } }; Some(GlobFileMatcher { matcher, kind }) } fn pattern_for_glob_pattern(&self, glob_pattern: GlobPattern) -> Option { match glob_pattern { GlobPattern::String(pattern) => Some(pattern), GlobPattern::Relative(relative_pattern) => { self.pattern_for_relative_pattern(relative_pattern) } } } fn pattern_for_relative_pattern(&self, relative_pattern: RelativePattern) -> Option { let RelativePattern { base_uri, pattern } = relative_pattern; let base_path = match base_uri { OneOf::Left(folder) => folder.uri, OneOf::Right(uri) => uri, }; let base_path = match lsp_uri_to_path(&base_path) { Ok(path) => path, Err(e) => { let base_uri = base_path.as_str(); log::warn!("Failed to resolve relativePattern baseUri {base_uri}: {e}"); return None; } }; let Ok(base_relative) = base_path.strip_prefix(&self.workspace_root) else { return None; }; // Normalize to forward slashes so glob patterns and event paths are comparable across platforms (esp. Windows). let prefix = galaxy_util::path::normalize_relative_path_for_glob(base_relative); if prefix.is_empty() { Some(pattern) } else { Some(format!("{prefix}/{pattern}")) } } } #[derive(Debug)] struct GlobFileMatcher { matcher: GlobMatcher, kind: Option, } impl GlobFileMatcher { fn matches(&self, path_relative: &str, change_type: FileChangeType) -> bool { if let Some(kind) = self.kind { if let Some(required) = watch_kind_for_change_type(change_type) { if !kind.contains(required) { return false; } } } self.matcher.is_match(path_relative) } } fn watch_kind_for_change_type(change_type: FileChangeType) -> Option { match change_type { FileChangeType::CREATED => Some(WatchKind::Create), FileChangeType::CHANGED => Some(WatchKind::Change), FileChangeType::DELETED => Some(WatchKind::Delete), _ => None, } } /// Encapsulates text-document related operations and requests. This exists only for bookkeeping /// and discoverability. pub struct TextDocumentService<'a> { service: &'a LspService, open_documents: Arc>>, } impl<'a> TextDocumentService<'a> { pub fn document_is_open(&self, path: &PathBuf) -> Result { let open_documents = self .open_documents .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?; Ok(open_documents.contains_key(path)) } /// Returns the last synced buffer version for the document, if it is open. /// Returns None if the document is not open. pub fn last_synced_version(&self, path: &PathBuf) -> Result> { let open_documents = self .open_documents .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?; Ok(open_documents .get(path) .and_then(|state| state.last_synced_version)) } pub async fn did_open( &self, path: &Path, content: String, initial_version: usize, ) -> Result<()> { { // Drop the guard before await let mut open_documents = self .open_documents .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?; // Use entry API to check if already present if open_documents.contains_key(&path.to_path_buf()) { return Ok(()); } open_documents.insert( path.to_path_buf(), DocumentSyncState { last_synced_version: Some(initial_version), }, ); } self.service.log_to_server_log( LspServerLogLevel::Debug, format!( "didOpen -> server: file={} version={initial_version}", path.display() ), ); // Determine language ID from the file path let language_id = LanguageId::from_path(path) .ok_or_else(|| { anyhow::anyhow!( "Could not determine language ID for file: {}", path.display() ) })? .lsp_language_identifier() .to_owned(); let did_open_params = DidOpenTextDocumentParams { text_document: TextDocumentItem { uri: path_to_lsp_uri(path)?, language_id, version: initial_version as i32, text: content, }, }; self.service .send_notification::(did_open_params) } pub async fn did_close(&self, path: &Path) -> Result<()> { { // Drop the guard before await let mut open_documents = self .open_documents .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?; if open_documents.remove(&path.to_path_buf()).is_none() { return Ok(()); } } let did_close_params = DidCloseTextDocumentParams { text_document: TextDocumentIdentifier { uri: path_to_lsp_uri(path)?, }, }; self.service .send_notification::(did_close_params) } pub async fn did_change( &self, path: &Path, version: i32, deltas: Vec, ) -> Result<()> { { // Check if document is open let mut open_documents = self .open_documents .lock() .map_err(|_| anyhow::anyhow!("Failed to acquire lock on open documents"))?; let Some(state) = open_documents.get_mut(path) else { return Ok(()); }; state.last_synced_version = Some(version as usize); } let did_change_params = DidChangeTextDocumentParams { text_document: VersionedTextDocumentIdentifier { uri: path_to_lsp_uri(path)?, version, }, content_changes: deltas.into_iter().map(|delta| delta.into_lsp()).collect(), }; self.service .send_notification::(did_change_params) } pub async fn definition( &self, path: &Path, position: Position, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let definition_params = GotoDefinitionParams { text_document_position_params: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, work_done_progress_params: Default::default(), partial_result_params: Default::default(), }; let result = self .service .send_request::(definition_params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/definition failed: {e}"), ); } // The LSP spec says textDocument/definition can return null when no definition is found // Handle this case explicitly since GotoDefinitionResponse doesn't deserialize null properly let Some(response) = result? else { return Err(anyhow::anyhow!("No definition found or LSP busy")); }; match response { GotoDefinitionResponse::Scalar(location) => Ok(vec![location.into()]), GotoDefinitionResponse::Array(locations) => { Ok(locations.into_iter().map(Into::into).collect()) } GotoDefinitionResponse::Link(locations) => { Ok(locations.into_iter().map(Into::into).collect()) } } } pub async fn format( &self, path: &Path, options: FormattingOptions, ) -> anyhow::Result>> { let format_params = DocumentFormattingParams { text_document: TextDocumentIdentifier { uri: path_to_lsp_uri(path)?, }, options, work_done_progress_params: Default::default(), }; let result = self .service .send_request::(format_params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/formatting failed: {e}"), ); } // The LSP spec says textDocument/formatting can return null when formatting is not supported result.map(|edits_option| { edits_option.map(|text_edits| text_edits.into_iter().map(Into::into).collect()) }) } pub async fn hover( &self, path: &Path, position: Position, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let hover_params = HoverParams { text_document_position_params: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, work_done_progress_params: Default::default(), }; let result = self .service .send_request::(hover_params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/hover failed: {e}"), ); } Ok(result?.map(Into::into)) } pub async fn references( &self, path: &Path, position: Position, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let reference_params = ReferenceParams { text_document_position: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, work_done_progress_params: Default::default(), partial_result_params: Default::default(), context: lsp_types::ReferenceContext { include_declaration: true, }, }; let result = self .service .send_request::(reference_params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/references failed: {e}"), ); } // The LSP spec says textDocument/references can return null when no references are found Ok(result? .unwrap_or_default() .into_iter() .filter_map(|loc| ReferenceLocation::try_from(loc).ok()) .collect()) } pub async fn completion( &self, path: &Path, position: Position, trigger: Option, trigger_character: Option, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let completion_params = CompletionParams { text_document_position: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, work_done_progress_params: Default::default(), partial_result_params: Default::default(), context: Some(CompletionContext { trigger_kind: trigger.unwrap_or(CompletionTriggerKind::INVOKED), trigger_character, }), }; let result = self .service .send_request::(completion_params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/completion failed: {e}"), ); } Ok(result?.map(Into::into)) } pub async fn completion_resolve(&self, item: CompletionItem) -> anyhow::Result { let result = self .service .send_request::(item) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("completionItem/resolve failed: {e}"), ); } result } pub async fn signature_help( &self, path: &Path, position: Position, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let params = SignatureHelpParams { text_document_position_params: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, work_done_progress_params: Default::default(), context: None, }; let result = self .service .send_request::(params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/signatureHelp failed: {e}"), ); } Ok(result?.map(Into::into)) } pub async fn code_action( &self, path: &Path, range: LspRange, diagnostics: Vec, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let params = CodeActionParams { text_document: TextDocumentIdentifier { uri }, range, context: CodeActionContext { diagnostics, only: None, trigger_kind: None, }, work_done_progress_params: Default::default(), partial_result_params: Default::default(), }; let result = self .service .send_request::(params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/codeAction failed: {e}"), ); } Ok(result? .unwrap_or_default() .into_iter() .map(Into::into) .collect()) } pub async fn prepare_rename( &self, path: &Path, position: Position, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let params = TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }; let result = self .service .send_request::(params) .await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/prepareRename failed: {e}"), ); } Ok(result?.map(|response| match response { lsp_types::PrepareRenameResponse::Range(range) => PrepareRenameResult { range: range.into(), placeholder: None, }, lsp_types::PrepareRenameResponse::RangeWithPlaceholder { range, placeholder } => { PrepareRenameResult { range: range.into(), placeholder: Some(placeholder), } } lsp_types::PrepareRenameResponse::DefaultBehavior { .. } => PrepareRenameResult { range: Range { start: Location { line: 0, column: 0 }, end: Location { line: 0, column: 0 }, }, placeholder: None, }, })) } pub async fn rename( &self, path: &Path, position: Position, new_name: String, ) -> anyhow::Result> { let uri = path_to_lsp_uri(path)?; let params = RenameParams { text_document_position: TextDocumentPositionParams { text_document: TextDocumentIdentifier { uri }, position, }, new_name, work_done_progress_params: Default::default(), }; let result = self.service.send_request::(params).await; if let Err(e) = &result { self.service.log_to_server_log( LspServerLogLevel::Error, format!("textDocument/rename failed: {e}"), ); } let workspace_edit = match result? { Some(edit) => edit, None => return Ok(None), }; let file_edits = workspace_edit_to_file_edits(workspace_edit)?; Ok(Some(RenameResult { edits: file_edits })) } } fn workspace_edit_to_file_edits( workspace_edit: lsp_types::WorkspaceEdit, ) -> anyhow::Result> { let mut result: Vec = Vec::new(); if let Some(changes) = workspace_edit.changes { for (uri, edits) in changes { let path = lsp_uri_to_path(&uri)?; result.push(FileEdits { path, edits: edits.into_iter().map(Into::into).collect(), }); } } if let Some(document_changes) = workspace_edit.document_changes { match document_changes { lsp_types::DocumentChanges::Edits(edits) => { for edit in edits { let path = lsp_uri_to_path(&edit.text_document.uri)?; result.push(FileEdits { path, edits: edit .edits .into_iter() .map(|e| match e { lsp_types::OneOf::Left(text_edit) => text_edit.into(), lsp_types::OneOf::Right(annotated) => lsp_types::TextEdit { range: annotated.text_edit.range, new_text: annotated.text_edit.new_text, } .into(), }) .collect(), }); } } lsp_types::DocumentChanges::Operations(_) => { // File create/rename/delete operations are not supported yet log::warn!("workspace/rename returned file operations which are not yet supported"); } } } Ok(result) }