Unnamed repository; edit this file 'description' to name the repository.
feat(lsp): create and delete file operations (#15448)
Hook file creation and deletion into the LSP protocol. Adds support
for `will_create`, `did_create`, `will_delete`, and `did_delete`.
These calls are by their nature blocking. If we send `will_create` we
can't create until the call resolves (that's my understanding).
There's currently a default 5s timeout, if we notice large number
of LSPs hanging or taking more than e.g. 3s and impacting the developer
experience we could tone down the timeout for these operations.
| -rw-r--r-- | helix-lsp/src/client.rs | 101 | ||||
| -rw-r--r-- | helix-lsp/src/file_operations.rs | 13 | ||||
| -rw-r--r-- | helix-view/src/editor.rs | 90 | ||||
| -rw-r--r-- | helix-view/src/handlers/lsp.rs | 42 |
4 files changed, 192 insertions, 54 deletions
diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index ee67201b..ad19efae 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -616,8 +616,12 @@ impl Client { relative_pattern_support: Some(true), }), file_operations: Some(lsp::WorkspaceFileOperationsClientCapabilities { + will_create: Some(true), + did_create: Some(true), will_rename: Some(true), did_rename: Some(true), + will_delete: Some(true), + did_delete: Some(true), ..Default::default() }), diagnostic: Some(lsp::DiagnosticWorkspaceClientCapabilities { @@ -808,6 +812,47 @@ impl Client { }) } + fn file_operation_uri(path: &Path, is_dir: bool) -> Option<String> { + let url = if is_dir { + Url::from_directory_path(path) + } else { + Url::from_file_path(path) + }; + Some(url.ok()?.to_string()) + } + + pub fn will_create( + &self, + path: &Path, + is_dir: bool, + ) -> Option<impl Future<Output = Result<Option<lsp::WorkspaceEdit>>>> { + let capabilities = self.file_operations_intests(); + if !capabilities.will_create.has_interest(path, is_dir) { + return None; + } + + let files = vec![lsp::FileCreate { + uri: Self::file_operation_uri(path, is_dir)?, + }]; + Some(self.call_with_timeout::<lsp::request::WillCreateFiles>( + &lsp::CreateFilesParams { files }, + 5, + )) + } + + pub fn did_create(&self, path: &Path, is_dir: bool) -> Option<()> { + let capabilities = self.file_operations_intests(); + if !capabilities.did_create.has_interest(path, is_dir) { + return None; + } + + let files = vec![lsp::FileCreate { + uri: Self::file_operation_uri(path, is_dir)?, + }]; + self.notify::<lsp::notification::DidCreateFiles>(lsp::CreateFilesParams { files }); + Some(()) + } + pub fn will_rename( &self, old_path: &Path, @@ -818,17 +863,9 @@ impl Client { if !capabilities.will_rename.has_interest(old_path, is_dir) { return None; } - let url_from_path = |path| { - let url = if is_dir { - Url::from_directory_path(path) - } else { - Url::from_file_path(path) - }; - Some(url.ok()?.to_string()) - }; let files = vec![lsp::FileRename { - old_uri: url_from_path(old_path)?, - new_uri: url_from_path(new_path)?, + old_uri: Self::file_operation_uri(old_path, is_dir)?, + new_uri: Self::file_operation_uri(new_path, is_dir)?, }]; Some(self.call_with_timeout::<lsp::request::WillRenameFiles>( &lsp::RenameFilesParams { files }, @@ -841,23 +878,47 @@ impl Client { if !capabilities.did_rename.has_interest(new_path, is_dir) { return None; } - let url_from_path = |path| { - let url = if is_dir { - Url::from_directory_path(path) - } else { - Url::from_file_path(path) - }; - Some(url.ok()?.to_string()) - }; let files = vec![lsp::FileRename { - old_uri: url_from_path(old_path)?, - new_uri: url_from_path(new_path)?, + old_uri: Self::file_operation_uri(old_path, is_dir)?, + new_uri: Self::file_operation_uri(new_path, is_dir)?, }]; self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files }); Some(()) } + pub fn will_delete( + &self, + path: &Path, + is_dir: bool, + ) -> Option<impl Future<Output = Result<Option<lsp::WorkspaceEdit>>>> { + let capabilities = self.file_operations_intests(); + if !capabilities.will_delete.has_interest(path, is_dir) { + return None; + } + + let files = vec![lsp::FileDelete { + uri: Self::file_operation_uri(path, is_dir)?, + }]; + Some(self.call_with_timeout::<lsp::request::WillDeleteFiles>( + &lsp::DeleteFilesParams { files }, + 5, + )) + } + + pub fn did_delete(&self, path: &Path, is_dir: bool) -> Option<()> { + let capabilities = self.file_operations_intests(); + if !capabilities.did_delete.has_interest(path, is_dir) { + return None; + } + + let files = vec![lsp::FileDelete { + uri: Self::file_operation_uri(path, is_dir)?, + }]; + self.notify::<lsp::notification::DidDeleteFiles>(lsp::DeleteFilesParams { files }); + Some(()) + } + // ------------------------------------------------------------------------------------------- // Text document // ------------------------------------------------------------------------------------------- diff --git a/helix-lsp/src/file_operations.rs b/helix-lsp/src/file_operations.rs index 98ac32a4..903ab303 100644 --- a/helix-lsp/src/file_operations.rs +++ b/helix-lsp/src/file_operations.rs @@ -79,13 +79,12 @@ impl FileOperationFilter { #[derive(Default, Debug)] pub(crate) struct FileOperationsInterest { - // TODO: support other notifications - // did_create: FileOperationFilter, - // will_create: FileOperationFilter, + pub did_create: FileOperationFilter, + pub will_create: FileOperationFilter, pub did_rename: FileOperationFilter, pub will_rename: FileOperationFilter, - // did_delete: FileOperationFilter, - // will_delete: FileOperationFilter, + pub did_delete: FileOperationFilter, + pub will_delete: FileOperationFilter, } impl FileOperationsInterest { @@ -98,8 +97,12 @@ impl FileOperationsInterest { return FileOperationsInterest::default(); }; FileOperationsInterest { + did_create: FileOperationFilter::new(capabilities.did_create.as_ref()), + will_create: FileOperationFilter::new(capabilities.will_create.as_ref()), did_rename: FileOperationFilter::new(capabilities.did_rename.as_ref()), will_rename: FileOperationFilter::new(capabilities.will_rename.as_ref()), + did_delete: FileOperationFilter::new(capabilities.did_delete.as_ref()), + will_delete: FileOperationFilter::new(capabilities.will_delete.as_ref()), } } } diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 62cf3592..104adb39 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -1598,6 +1598,96 @@ impl Editor { Ok(()) } + pub fn create_path(&mut self, path: &Path, is_dir: bool) -> io::Result<()> { + let path = canonicalize(path); + let language_servers: Vec<_> = self + .language_servers + .iter_clients() + .filter(|client| client.is_initialized()) + .cloned() + .collect(); + for language_server in language_servers { + let Some(request) = language_server.will_create(&path, is_dir) else { + continue; + }; + let edit = match helix_lsp::block_on(request) { + Ok(edit) => edit.unwrap_or_default(), + Err(err) => { + log::error!("invalid willCreate response: {err:?}"); + continue; + } + }; + if let Err(err) = self.apply_workspace_edit(language_server.offset_encoding(), &edit) { + log::error!("failed to apply workspace edit: {err:?}") + } + } + + if let Some(dir) = path.parent() { + if !dir.is_dir() { + fs::create_dir_all(dir)?; + } + } + if is_dir { + fs::create_dir(&path)?; + } else { + fs::write(&path, [])?; + } + + for ls in self.language_servers.iter_clients() { + if !ls.is_initialized() { + continue; + } + ls.did_create(&path, is_dir); + } + self.language_servers.file_event_handler.file_changed(path); + Ok(()) + } + + pub fn delete_path(&mut self, path: &Path, recursive: bool) -> io::Result<()> { + let path = canonicalize(path); + let is_dir = path.is_dir(); + let language_servers: Vec<_> = self + .language_servers + .iter_clients() + .filter(|client| client.is_initialized()) + .cloned() + .collect(); + for language_server in language_servers { + let Some(request) = language_server.will_delete(&path, is_dir) else { + continue; + }; + let edit = match helix_lsp::block_on(request) { + Ok(edit) => edit.unwrap_or_default(), + Err(err) => { + log::error!("invalid willDelete response: {err:?}"); + continue; + } + }; + if let Err(err) = self.apply_workspace_edit(language_server.offset_encoding(), &edit) { + log::error!("failed to apply workspace edit: {err:?}") + } + } + + if is_dir { + if recursive { + fs::remove_dir_all(&path)?; + } else { + fs::remove_dir(&path)?; + } + } else { + fs::remove_file(&path)?; + } + + for ls in self.language_servers.iter_clients() { + if !ls.is_initialized() { + continue; + } + ls.did_delete(&path, is_dir); + } + self.language_servers.file_event_handler.file_changed(path); + Ok(()) + } + pub fn set_doc_path(&mut self, doc_id: DocumentId, path: &Path) { let doc = doc_mut!(self, &doc_id); let old_path = doc.path(); diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index b052138f..ecdbfdf7 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -230,7 +230,6 @@ impl Editor { op: &lsp::ResourceOp, ) -> Result<(), ApplyEditErrorKind> { use lsp::ResourceOp; - use std::fs; // NOTE: If `Uri` gets another variant than `Path`, the below `expect`s // may no longer be valid. match op { @@ -241,40 +240,25 @@ impl Editor { !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) }); if !ignore_if_exists || !path.exists() { - // Create directory if it does not exist - if let Some(dir) = path.parent() { - if !dir.is_dir() { - fs::create_dir_all(dir)?; - } - } - - fs::write(path, [])?; - self.language_servers - .file_event_handler - .file_changed(path.to_path_buf()); + self.create_path(path, false)?; } } ResourceOp::Delete(op) => { let uri = Uri::try_from(&op.uri)?; let path = uri.as_path().expect("URIs are valid paths"); - if path.is_dir() { - let recursive = op - .options - .as_ref() - .and_then(|options| options.recursive) - .unwrap_or(false); - - if recursive { - fs::remove_dir_all(path)? - } else { - fs::remove_dir(path)? - } - self.language_servers - .file_event_handler - .file_changed(path.to_path_buf()); - } else if path.is_file() { - fs::remove_file(path)?; + let ignore_if_not_exists = op + .options + .as_ref() + .is_some_and(|options| options.ignore_if_not_exists.unwrap_or(false)); + if ignore_if_not_exists && !path.exists() { + return Ok(()); } + let recursive = op + .options + .as_ref() + .and_then(|options| options.recursive) + .unwrap_or(false); + self.delete_path(path, recursive)?; } ResourceOp::Rename(op) => { let from_uri = Uri::try_from(&op.old_uri)?; |