From 87a720c3a13ccc7245f5b0befc008db5bd039032 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Sun, 28 Jan 2024 17:34:45 +0100 Subject: [PATCH] make path changes LSP spec conform (#8949) Currently, helix implements operations which change the paths of files incorrectly and inconsistently. This PR ensures that we do the following whenever a buffer is renamed (`:move` and workspace edits) * always send did_open/did_close notifications * send will_rename/did_rename requests correctly * send them to all LSP servers not just those that are active for a buffer * also send these requests for paths that are not yet open in a buffer (if triggered from workspace edit). * only send these if the server registered interests in the path * autodetect language, indent, line ending, .. This PR also centralizes the infrastructure for path setting and therefore `:w ` benefits from similar fixed (but without didRename) --- helix-lsp/src/client.rs | 83 ++++++----- helix-lsp/src/file_operations.rs | 105 ++++++++++++++ helix-lsp/src/lib.rs | 1 + helix-term/src/application.rs | 31 +---- helix-term/src/commands/lsp.rs | 198 +------------------------- helix-term/src/commands/typed.rs | 62 +-------- helix-view/src/document.rs | 3 + helix-view/src/editor.rs | 90 +++++++++++- helix-view/src/handlers/lsp.rs | 229 +++++++++++++++++++++++++++++++ 9 files changed, 483 insertions(+), 319 deletions(-) create mode 100644 helix-lsp/src/file_operations.rs diff --git a/helix-lsp/src/client.rs b/helix-lsp/src/client.rs index fb32f6eb..94bad6fa 100644 --- a/helix-lsp/src/client.rs +++ b/helix-lsp/src/client.rs @@ -1,4 +1,5 @@ use crate::{ + file_operations::FileOperationsInterest, find_lsp_workspace, jsonrpc, transport::{Payload, Transport}, Call, Error, OffsetEncoding, Result, @@ -9,20 +10,20 @@ use helix_stdx::path; use lsp::{ notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, - DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, WorkspaceFolder, - WorkspaceFoldersChangeEvent, + DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, Url, + WorkspaceFolder, WorkspaceFoldersChangeEvent, }; use lsp_types as lsp; use parking_lot::Mutex; use serde::Deserialize; use serde_json::Value; -use std::future::Future; -use std::process::Stdio; use std::sync::{ atomic::{AtomicU64, Ordering}, Arc, }; use std::{collections::HashMap, path::PathBuf}; +use std::{future::Future, sync::OnceLock}; +use std::{path::Path, process::Stdio}; use tokio::{ io::{BufReader, BufWriter}, process::{Child, Command}, @@ -51,6 +52,7 @@ pub struct Client { server_tx: UnboundedSender, request_counter: AtomicU64, pub(crate) capabilities: OnceCell, + pub(crate) file_operation_interest: OnceLock, config: Option, root_path: std::path::PathBuf, root_uri: Option, @@ -233,6 +235,7 @@ pub fn start( server_tx, request_counter: AtomicU64::new(0), capabilities: OnceCell::new(), + file_operation_interest: OnceLock::new(), config, req_timeout, root_path, @@ -278,6 +281,11 @@ pub fn capabilities(&self) -> &lsp::ServerCapabilities { .expect("language server not yet initialized!") } + pub(crate) fn file_operations_intests(&self) -> &FileOperationsInterest { + self.file_operation_interest + .get_or_init(|| FileOperationsInterest::new(self.capabilities())) + } + /// Client has to be initialized otherwise this function panics #[inline] pub fn supports_feature(&self, feature: LanguageServerFeature) -> bool { @@ -717,27 +725,27 @@ pub fn did_change_workspace( }) } - pub fn prepare_file_rename( + pub fn will_rename( &self, - old_uri: &lsp::Url, - new_uri: &lsp::Url, + old_path: &Path, + new_path: &Path, + is_dir: bool, ) -> Option>> { - let capabilities = self.capabilities.get().unwrap(); - - // Return early if the server does not support willRename feature - match &capabilities.workspace { - Some(workspace) => match &workspace.file_operations { - Some(op) => { - op.will_rename.as_ref()?; - } - _ => return None, - }, - _ => return None, + let capabilities = self.file_operations_intests(); + 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: old_uri.to_string(), - new_uri: new_uri.to_string(), + old_uri: url_from_path(old_path)?, + new_uri: url_from_path(new_path)?, }]; let request = self.call_with_timeout::( lsp::RenameFilesParams { files }, @@ -751,27 +759,28 @@ pub fn prepare_file_rename( }) } - pub fn did_file_rename( + pub fn did_rename( &self, - old_uri: &lsp::Url, - new_uri: &lsp::Url, + old_path: &Path, + new_path: &Path, + is_dir: bool, ) -> Option>> { - let capabilities = self.capabilities.get().unwrap(); - - // Return early if the server does not support DidRename feature - match &capabilities.workspace { - Some(workspace) => match &workspace.file_operations { - Some(op) => { - op.did_rename.as_ref()?; - } - _ => return None, - }, - _ => return None, + let capabilities = self.file_operations_intests(); + 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: old_uri.to_string(), - new_uri: new_uri.to_string(), + old_uri: url_from_path(old_path)?, + new_uri: url_from_path(new_path)?, }]; Some(self.notify::(lsp::RenameFilesParams { files })) } diff --git a/helix-lsp/src/file_operations.rs b/helix-lsp/src/file_operations.rs new file mode 100644 index 00000000..98ac32a4 --- /dev/null +++ b/helix-lsp/src/file_operations.rs @@ -0,0 +1,105 @@ +use std::path::Path; + +use globset::{GlobBuilder, GlobSet}; + +use crate::lsp; + +#[derive(Default, Debug)] +pub(crate) struct FileOperationFilter { + dir_globs: GlobSet, + file_globs: GlobSet, +} + +impl FileOperationFilter { + fn new(capability: Option<&lsp::FileOperationRegistrationOptions>) -> FileOperationFilter { + let Some(cap) = capability else { + return FileOperationFilter::default(); + }; + let mut dir_globs = GlobSet::builder(); + let mut file_globs = GlobSet::builder(); + for filter in &cap.filters { + // TODO: support other url schemes + let is_non_file_schema = filter + .scheme + .as_ref() + .is_some_and(|schema| schema != "file"); + if is_non_file_schema { + continue; + } + let ignore_case = filter + .pattern + .options + .as_ref() + .and_then(|opts| opts.ignore_case) + .unwrap_or(false); + let mut glob_builder = GlobBuilder::new(&filter.pattern.glob); + glob_builder.case_insensitive(!ignore_case); + let glob = match glob_builder.build() { + Ok(glob) => glob, + Err(err) => { + log::error!("invalid glob send by LS: {err}"); + continue; + } + }; + match filter.pattern.matches { + Some(lsp::FileOperationPatternKind::File) => { + file_globs.add(glob); + } + Some(lsp::FileOperationPatternKind::Folder) => { + dir_globs.add(glob); + } + None => { + file_globs.add(glob.clone()); + dir_globs.add(glob); + } + }; + } + let file_globs = file_globs.build().unwrap_or_else(|err| { + log::error!("invalid globs send by LS: {err}"); + GlobSet::empty() + }); + let dir_globs = dir_globs.build().unwrap_or_else(|err| { + log::error!("invalid globs send by LS: {err}"); + GlobSet::empty() + }); + FileOperationFilter { + dir_globs, + file_globs, + } + } + + pub(crate) fn has_interest(&self, path: &Path, is_dir: bool) -> bool { + if is_dir { + self.dir_globs.is_match(path) + } else { + self.file_globs.is_match(path) + } + } +} + +#[derive(Default, Debug)] +pub(crate) struct FileOperationsInterest { + // TODO: support other notifications + // did_create: FileOperationFilter, + // will_create: FileOperationFilter, + pub did_rename: FileOperationFilter, + pub will_rename: FileOperationFilter, + // did_delete: FileOperationFilter, + // will_delete: FileOperationFilter, +} + +impl FileOperationsInterest { + pub fn new(capabilities: &lsp::ServerCapabilities) -> FileOperationsInterest { + let capabilities = capabilities + .workspace + .as_ref() + .and_then(|capabilities| capabilities.file_operations.as_ref()); + let Some(capabilities) = capabilities else { + return FileOperationsInterest::default(); + }; + FileOperationsInterest { + did_rename: FileOperationFilter::new(capabilities.did_rename.as_ref()), + will_rename: FileOperationFilter::new(capabilities.will_rename.as_ref()), + } + } +} diff --git a/helix-lsp/src/lib.rs b/helix-lsp/src/lib.rs index 53b2712d..4ce445ae 100644 --- a/helix-lsp/src/lib.rs +++ b/helix-lsp/src/lib.rs @@ -1,5 +1,6 @@ mod client; pub mod file_event; +mod file_operations; pub mod jsonrpc; pub mod snippet; mod transport; diff --git a/helix-term/src/application.rs b/helix-term/src/application.rs index 3f3e59c6..b5150a13 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -21,7 +21,6 @@ use crate::{ args::Args, - commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, handlers, @@ -573,26 +572,8 @@ pub fn handle_document_write(&mut self, doc_save_event: DocumentSavedEventResult let lines = doc_save_event.text.len_lines(); let bytes = doc_save_event.text.len_bytes(); - if doc.path() != Some(&doc_save_event.path) { - doc.set_path(Some(&doc_save_event.path)); - - let loader = self.editor.syn_loader.clone(); - - // borrowing the same doc again to get around the borrow checker - let doc = doc_mut!(self.editor, &doc_save_event.doc_id); - let id = doc.id(); - doc.detect_language(loader); - self.editor.refresh_language_servers(id); - // and again a borrow checker workaround... - let doc = doc_mut!(self.editor, &doc_save_event.doc_id); - let diagnostics = Editor::doc_diagnostics( - &self.editor.language_servers, - &self.editor.diagnostics, - doc, - ); - doc.replace_diagnostics(diagnostics, &[], None); - } - + self.editor + .set_doc_path(doc_save_event.doc_id, &doc_save_event.path); // TODO: fix being overwritten by lsp self.editor.set_status(format!( "'{}' written, {}L {}B", @@ -1011,11 +992,9 @@ macro_rules! language_server { let language_server = language_server!(); if language_server.is_initialized() { let offset_encoding = language_server.offset_encoding(); - let res = apply_workspace_edit( - &mut self.editor, - offset_encoding, - ¶ms.edit, - ); + let res = self + .editor + .apply_workspace_edit(offset_encoding, ¶ms.edit); Ok(json!(lsp::ApplyWorkspaceEditResponse { applied: res.is_ok(), diff --git a/helix-term/src/commands/lsp.rs b/helix-term/src/commands/lsp.rs index c694ba25..a1f7bf17 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -726,8 +726,7 @@ pub fn code_action(cx: &mut Context) { resolved_code_action.as_ref().unwrap_or(code_action); if let Some(ref workspace_edit) = resolved_code_action.edit { - log::debug!("edit: {:?}", workspace_edit); - let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit); + let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit); } // if code action provides both edit and command first the edit @@ -787,63 +786,6 @@ pub fn execute_lsp_command(editor: &mut Editor, language_server_id: usize, cmd: }); } -pub fn apply_document_resource_op(op: &lsp::ResourceOp) -> std::io::Result<()> { - use lsp::ResourceOp; - use std::fs; - match op { - ResourceOp::Create(op) => { - let path = op.uri.to_file_path().unwrap(); - let ignore_if_exists = op.options.as_ref().map_or(false, |options| { - !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - }); - if ignore_if_exists && path.exists() { - Ok(()) - } else { - // 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, []) - } - } - ResourceOp::Delete(op) => { - let path = op.uri.to_file_path().unwrap(); - 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) - } - } else if path.is_file() { - fs::remove_file(&path) - } else { - Ok(()) - } - } - ResourceOp::Rename(op) => { - let from = op.old_uri.to_file_path().unwrap(); - let to = op.new_uri.to_file_path().unwrap(); - let ignore_if_exists = op.options.as_ref().map_or(false, |options| { - !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) - }); - if ignore_if_exists && to.exists() { - Ok(()) - } else { - fs::rename(from, &to) - } - } - } -} - #[derive(Debug)] pub struct ApplyEditError { pub kind: ApplyEditErrorKind, @@ -871,142 +813,6 @@ fn to_string(&self) -> String { } } -///TODO make this transactional (and set failureMode to transactional) -pub fn apply_workspace_edit( - editor: &mut Editor, - offset_encoding: OffsetEncoding, - workspace_edit: &lsp::WorkspaceEdit, -) -> Result<(), ApplyEditError> { - let mut apply_edits = |uri: &helix_lsp::Url, - version: Option, - text_edits: Vec| - -> Result<(), ApplyEditErrorKind> { - let path = match uri.to_file_path() { - Ok(path) => path, - Err(_) => { - let err = format!("unable to convert URI to filepath: {}", uri); - log::error!("{}", err); - editor.set_error(err); - return Err(ApplyEditErrorKind::UnknownURISchema); - } - }; - - let doc_id = match editor.open(&path, Action::Load) { - Ok(doc_id) => doc_id, - Err(err) => { - let err = format!("failed to open document: {}: {}", uri, err); - log::error!("{}", err); - editor.set_error(err); - return Err(ApplyEditErrorKind::FileNotFound); - } - }; - - let doc = doc!(editor, &doc_id); - if let Some(version) = version { - if version != doc.version() { - let err = format!("outdated workspace edit for {path:?}"); - log::error!("{err}, expected {} but got {version}", doc.version()); - editor.set_error(err); - return Err(ApplyEditErrorKind::DocumentChanged); - } - } - - // Need to determine a view for apply/append_changes_to_history - let view_id = editor.get_synced_view_id(doc_id); - let doc = doc_mut!(editor, &doc_id); - - let transaction = helix_lsp::util::generate_transaction_from_edits( - doc.text(), - text_edits, - offset_encoding, - ); - let view = view_mut!(editor, view_id); - doc.apply(&transaction, view.id); - doc.append_changes_to_history(view); - Ok(()) - }; - - if let Some(ref document_changes) = workspace_edit.document_changes { - match document_changes { - lsp::DocumentChanges::Edits(document_edits) => { - for (i, document_edit) in document_edits.iter().enumerate() { - let edits = document_edit - .edits - .iter() - .map(|edit| match edit { - lsp::OneOf::Left(text_edit) => text_edit, - lsp::OneOf::Right(annotated_text_edit) => { - &annotated_text_edit.text_edit - } - }) - .cloned() - .collect(); - apply_edits( - &document_edit.text_document.uri, - document_edit.text_document.version, - edits, - ) - .map_err(|kind| ApplyEditError { - kind, - failed_change_idx: i, - })?; - } - } - lsp::DocumentChanges::Operations(operations) => { - log::debug!("document changes - operations: {:?}", operations); - for (i, operation) in operations.iter().enumerate() { - match operation { - lsp::DocumentChangeOperation::Op(op) => { - apply_document_resource_op(op).map_err(|io| ApplyEditError { - kind: ApplyEditErrorKind::IoError(io), - failed_change_idx: i, - })?; - } - - lsp::DocumentChangeOperation::Edit(document_edit) => { - let edits = document_edit - .edits - .iter() - .map(|edit| match edit { - lsp::OneOf::Left(text_edit) => text_edit, - lsp::OneOf::Right(annotated_text_edit) => { - &annotated_text_edit.text_edit - } - }) - .cloned() - .collect(); - apply_edits( - &document_edit.text_document.uri, - document_edit.text_document.version, - edits, - ) - .map_err(|kind| ApplyEditError { - kind, - failed_change_idx: i, - })?; - } - } - } - } - } - - return Ok(()); - } - - if let Some(ref changes) = workspace_edit.changes { - log::debug!("workspace changes: {:?}", changes); - for (i, (uri, text_edits)) in changes.iter().enumerate() { - let text_edits = text_edits.to_vec(); - apply_edits(uri, None, text_edits).map_err(|kind| ApplyEditError { - kind, - failed_change_idx: i, - })?; - } - } - - Ok(()) -} - /// Precondition: `locations` should be non-empty. fn goto_impl( editor: &mut Editor, @@ -1263,7 +1069,7 @@ fn create_rename_prompt( match block_on(future) { Ok(edits) => { - let _ = apply_workspace_edit(cx.editor, offset_encoding, &edits); + let _ = cx.editor.apply_workspace_edit(offset_encoding, &edits); } Err(err) => cx.editor.set_error(err.to_string()), } diff --git a/helix-term/src/commands/typed.rs b/helix-term/src/commands/typed.rs index 81ffdf87..b7ceeba5 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -8,7 +8,6 @@ use helix_core::fuzzy::fuzzy_match; use helix_core::indent::MAX_INDENT; use helix_core::{encoding, line_ending, shellwords::Shellwords}; -use helix_lsp::{OffsetEncoding, Url}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; @@ -2404,67 +2403,14 @@ fn move_buffer( ensure!(args.len() == 1, format!(":move takes one argument")); let doc = doc!(cx.editor); - - let new_path = - helix_stdx::path::canonicalize(&PathBuf::from(args.first().unwrap().to_string())); let old_path = doc .path() - .ok_or_else(|| anyhow!("Scratch buffer cannot be moved. Use :write instead"))? + .context("Scratch buffer cannot be moved. Use :write instead")? .clone(); - let old_path_as_url = doc.url().unwrap(); - let new_path_as_url = Url::from_file_path(&new_path).unwrap(); - - let edits: Vec<( - helix_lsp::Result, - OffsetEncoding, - String, - )> = doc - .language_servers() - .map(|lsp| { - ( - lsp.prepare_file_rename(&old_path_as_url, &new_path_as_url), - lsp.offset_encoding(), - lsp.name().to_owned(), - ) - }) - .filter(|(f, _, _)| f.is_some()) - .map(|(f, encoding, name)| (helix_lsp::block_on(f.unwrap()), encoding, name)) - .collect(); - - for (lsp_reply, encoding, name) in edits { - match lsp_reply { - Ok(edit) => { - if let Err(e) = apply_workspace_edit(cx.editor, encoding, &edit) { - log::error!( - ":move command failed to apply edits from lsp {}: {:?}", - name, - e - ); - }; - } - Err(e) => { - log::error!("LSP {} failed to treat willRename request: {:?}", name, e); - } - }; + let new_path = args.first().unwrap().to_string(); + if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) { + bail!("Could not move file: {err}"); } - - let doc = doc_mut!(cx.editor); - - doc.set_path(Some(new_path.as_path())); - if let Err(e) = std::fs::rename(&old_path, &new_path) { - doc.set_path(Some(old_path.as_path())); - bail!("Could not move file: {}", e); - }; - - doc.language_servers().for_each(|lsp| { - lsp.did_file_rename(&old_path_as_url, &new_path_as_url); - }); - - cx.editor - .language_servers - .file_event_handler - .file_changed(new_path); - Ok(()) } diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs index 88653948..33137c6c 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1041,6 +1041,9 @@ pub fn encoding(&self) -> &'static Encoding { self.encoding } + /// sets the document path without sending events to various + /// observers (like LSP), in most cases `Editor::set_doc_path` + /// should be used instead pub fn set_path(&mut self, path: Option<&Path>) { let path = path.map(helix_stdx::path::canonicalize); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index eca488e7..db0d4030 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -23,7 +23,8 @@ borrow::Cow, cell::Cell, collections::{BTreeMap, HashMap}, - io::stdin, + fs, + io::{self, stdin}, num::NonZeroUsize, path::{Path, PathBuf}, pin::Pin, @@ -45,6 +46,7 @@ }; use helix_dap as dap; use helix_lsp::lsp; +use helix_stdx::path::canonicalize; use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; @@ -1215,6 +1217,90 @@ pub fn refresh_language_servers(&mut self, doc_id: DocumentId) { self.launch_language_servers(doc_id) } + /// moves/renames a path, invoking any event handlers (currently only lsp) + /// and calling `set_doc_path` if the file is open in the editor + pub fn move_path(&mut self, old_path: &Path, new_path: &Path) -> io::Result<()> { + let new_path = canonicalize(new_path); + // sanity check + if old_path == new_path { + return Ok(()); + } + let is_dir = old_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_rename(old_path, &new_path, is_dir) else { + continue; + }; + let edit = match helix_lsp::block_on(request) { + Ok(edit) => edit, + Err(err) => { + log::error!("invalid willRename response: {err:?}"); + continue; + } + }; + if let Err(err) = self.apply_workspace_edit(language_server.offset_encoding(), &edit) { + log::error!("failed to apply workspace edit: {err:?}") + } + } + fs::rename(old_path, &new_path)?; + if let Some(doc) = self.document_by_path(old_path) { + self.set_doc_path(doc.id(), &new_path); + } + let is_dir = new_path.is_dir(); + for ls in self.language_servers.iter_clients() { + if let Some(notification) = ls.did_rename(old_path, &new_path, is_dir) { + tokio::spawn(notification); + }; + } + self.language_servers + .file_event_handler + .file_changed(old_path.to_owned()); + self.language_servers + .file_event_handler + .file_changed(new_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(); + + if let Some(old_path) = old_path { + // sanity check, should not occur but some callers (like an LSP) may + // create bogus calls + if old_path == path { + return; + } + // if we are open in LSPs send did_close notification + for language_server in doc.language_servers() { + tokio::spawn(language_server.text_document_did_close(doc.identifier())); + } + } + // we need to clear the list of language servers here so that + // refresh_doc_language/refresh_language_servers doesn't resend + // text_document_did_close. Since we called `text_document_did_close` + // we have fully unregistered this document from its LS + doc.language_servers.clear(); + doc.set_path(Some(path)); + self.refresh_doc_language(doc_id) + } + + pub fn refresh_doc_language(&mut self, doc_id: DocumentId) { + let loader = self.syn_loader.clone(); + let doc = doc_mut!(self, &doc_id); + doc.detect_language(loader); + doc.detect_indent_and_line_ending(); + self.refresh_language_servers(doc_id); + let doc = doc_mut!(self, &doc_id); + let diagnostics = Editor::doc_diagnostics(&self.language_servers, &self.diagnostics, doc); + doc.replace_diagnostics(diagnostics, &[], None); + } + /// Launch a language server for a given document fn launch_language_servers(&mut self, doc_id: DocumentId) { if !self.config().lsp.enable { @@ -1257,7 +1343,7 @@ fn launch_language_servers(&mut self, doc_id: DocumentId) { .collect::>() }); - if language_servers.is_empty() { + if language_servers.is_empty() && doc.language_servers.is_empty() { return; } diff --git a/helix-view/src/handlers/lsp.rs b/helix-view/src/handlers/lsp.rs index 1dae45dd..beb106b2 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -1,4 +1,8 @@ +use crate::editor::Action; +use crate::Editor; use crate::{DocumentId, ViewId}; +use helix_lsp::util::generate_transaction_from_edits; +use helix_lsp::{lsp, OffsetEncoding}; pub enum CompletionEvent { /// Auto completion was triggered by typing a word char @@ -39,3 +43,228 @@ pub enum SignatureHelpEvent { Cancel, RequestComplete { open: bool }, } + +#[derive(Debug)] +pub struct ApplyEditError { + pub kind: ApplyEditErrorKind, + pub failed_change_idx: usize, +} + +#[derive(Debug)] +pub enum ApplyEditErrorKind { + DocumentChanged, + FileNotFound, + UnknownURISchema, + IoError(std::io::Error), + // TODO: check edits before applying and propagate failure + // InvalidEdit, +} + +impl ToString for ApplyEditErrorKind { + fn to_string(&self) -> String { + match self { + ApplyEditErrorKind::DocumentChanged => "document has changed".to_string(), + ApplyEditErrorKind::FileNotFound => "file not found".to_string(), + ApplyEditErrorKind::UnknownURISchema => "URI schema not supported".to_string(), + ApplyEditErrorKind::IoError(err) => err.to_string(), + } + } +} + +impl Editor { + fn apply_text_edits( + &mut self, + uri: &helix_lsp::Url, + version: Option, + text_edits: Vec, + offset_encoding: OffsetEncoding, + ) -> Result<(), ApplyEditErrorKind> { + let path = match uri.to_file_path() { + Ok(path) => path, + Err(_) => { + let err = format!("unable to convert URI to filepath: {}", uri); + log::error!("{}", err); + self.set_error(err); + return Err(ApplyEditErrorKind::UnknownURISchema); + } + }; + + let doc_id = match self.open(&path, Action::Load) { + Ok(doc_id) => doc_id, + Err(err) => { + let err = format!("failed to open document: {}: {}", uri, err); + log::error!("{}", err); + self.set_error(err); + return Err(ApplyEditErrorKind::FileNotFound); + } + }; + + let doc = doc_mut!(self, &doc_id); + if let Some(version) = version { + if version != doc.version() { + let err = format!("outdated workspace edit for {path:?}"); + log::error!("{err}, expected {} but got {version}", doc.version()); + self.set_error(err); + return Err(ApplyEditErrorKind::DocumentChanged); + } + } + + // Need to determine a view for apply/append_changes_to_history + let view_id = self.get_synced_view_id(doc_id); + let doc = doc_mut!(self, &doc_id); + + let transaction = generate_transaction_from_edits(doc.text(), text_edits, offset_encoding); + let view = view_mut!(self, view_id); + doc.apply(&transaction, view.id); + doc.append_changes_to_history(view); + Ok(()) + } + + // TODO make this transactional (and set failureMode to transactional) + pub fn apply_workspace_edit( + &mut self, + offset_encoding: OffsetEncoding, + workspace_edit: &lsp::WorkspaceEdit, + ) -> Result<(), ApplyEditError> { + if let Some(ref document_changes) = workspace_edit.document_changes { + match document_changes { + lsp::DocumentChanges::Edits(document_edits) => { + for (i, document_edit) in document_edits.iter().enumerate() { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + self.apply_text_edits( + &document_edit.text_document.uri, + document_edit.text_document.version, + edits, + offset_encoding, + ) + .map_err(|kind| ApplyEditError { + kind, + failed_change_idx: i, + })?; + } + } + lsp::DocumentChanges::Operations(operations) => { + log::debug!("document changes - operations: {:?}", operations); + for (i, operation) in operations.iter().enumerate() { + match operation { + lsp::DocumentChangeOperation::Op(op) => { + self.apply_document_resource_op(op).map_err(|io| { + ApplyEditError { + kind: ApplyEditErrorKind::IoError(io), + failed_change_idx: i, + } + })?; + } + + lsp::DocumentChangeOperation::Edit(document_edit) => { + let edits = document_edit + .edits + .iter() + .map(|edit| match edit { + lsp::OneOf::Left(text_edit) => text_edit, + lsp::OneOf::Right(annotated_text_edit) => { + &annotated_text_edit.text_edit + } + }) + .cloned() + .collect(); + self.apply_text_edits( + &document_edit.text_document.uri, + document_edit.text_document.version, + edits, + offset_encoding, + ) + .map_err(|kind| { + ApplyEditError { + kind, + failed_change_idx: i, + } + })?; + } + } + } + } + } + + return Ok(()); + } + + if let Some(ref changes) = workspace_edit.changes { + log::debug!("workspace changes: {:?}", changes); + for (i, (uri, text_edits)) in changes.iter().enumerate() { + let text_edits = text_edits.to_vec(); + self.apply_text_edits(uri, None, text_edits, offset_encoding) + .map_err(|kind| ApplyEditError { + kind, + failed_change_idx: i, + })?; + } + } + + Ok(()) + } + + fn apply_document_resource_op(&mut self, op: &lsp::ResourceOp) -> std::io::Result<()> { + use lsp::ResourceOp; + use std::fs; + match op { + ResourceOp::Create(op) => { + let path = op.uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !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); + } + } + ResourceOp::Delete(op) => { + let path = op.uri.to_file_path().unwrap(); + 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); + } else if path.is_file() { + fs::remove_file(&path)?; + } + } + ResourceOp::Rename(op) => { + let from = op.old_uri.to_file_path().unwrap(); + let to = op.new_uri.to_file_path().unwrap(); + let ignore_if_exists = op.options.as_ref().map_or(false, |options| { + !options.overwrite.unwrap_or(false) && options.ignore_if_exists.unwrap_or(false) + }); + if !ignore_if_exists || !to.exists() { + self.move_path(&from, &to)?; + } + } + } + Ok(()) + } +}