From f74d6a54f035ebcd14d467a3a040d53d3047b507 Mon Sep 17 00:00:00 2001 From: Pascal Kuthe Date: Thu, 30 Nov 2023 01:19:17 +0100 Subject: [PATCH] make path changes LSP spec conform 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 | 81 ++++++----- helix-lsp/src/file_operations.rs | 105 ++++++++++++++ helix-lsp/src/lib.rs | 1 + helix-term/src/application.rs | 23 +-- helix-term/src/commands/lsp.rs | 236 +----------------------------- helix-term/src/commands/typed.rs | 63 +------- helix-view/src/document.rs | 3 + helix-view/src/editor.rs | 87 ++++++++++- helix-view/src/handlers/lsp.rs | 240 +++++++++++++++++++++++++++++++ 9 files changed, 491 insertions(+), 348 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 e6e1f8a03301..37d1ddac79d8 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, @@ -8,20 +9,20 @@ use helix_core::{find_workspace, path, syntax::LanguageServerFeature, ChangeSet, use helix_loader::{self, VERSION_AND_GIT_HASH}; use lsp::{ notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, - DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, WorkspaceFolder, + DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, 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}, @@ -50,6 +51,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, @@ -232,6 +234,7 @@ impl Client { server_tx, request_counter: AtomicU64::new(0), capabilities: OnceCell::new(), + file_operation_interest: OnceLock::new(), config, req_timeout, root_path, @@ -277,6 +280,11 @@ impl Client { .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 { @@ -715,27 +723,27 @@ impl Client { }) } - 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 }, @@ -749,27 +757,28 @@ impl Client { }) } - 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 000000000000..98ac32a40989 --- /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 b6a990659aaa..c02a830dafa1 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 ed085749b6d3..4c3a3b6c72c6 100644 --- a/helix-term/src/application.rs +++ b/helix-term/src/application.rs @@ -24,7 +24,6 @@ use tui::backend::Backend; use crate::{ args::Args, - commands::apply_workspace_edit, compositor::{Compositor, Event}, config::Config, job::Jobs, @@ -556,18 +555,8 @@ impl Application { 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); - } - + 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", @@ -1029,11 +1018,9 @@ impl Application { 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 874004616c76..8936b2946ddb 100644 --- a/helix-term/src/commands/lsp.rs +++ b/helix-term/src/commands/lsp.rs @@ -730,8 +730,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 @@ -782,237 +781,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, - 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(), - } - } -} - -///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 current_view_id = view!(editor).id; - 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_mut!(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 selections = doc.selections(); - let view_id = if selections.contains_key(¤t_view_id) { - // use current if possible - current_view_id - } else { - // Hack: we take the first available view_id - selections - .keys() - .next() - .copied() - .expect("No view_id available") - }; - - 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(()) -} - fn goto_impl( editor: &mut Editor, compositor: &mut Compositor, @@ -1398,7 +1166,7 @@ pub fn rename_symbol(cx: &mut Context) { 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 4148257fc494..08f8d4aa56ba 100644 --- a/helix-term/src/commands/typed.rs +++ b/helix-term/src/commands/typed.rs @@ -6,8 +6,7 @@ use crate::job::Job; use super::*; use helix_core::fuzzy::fuzzy_match; -use helix_core::{encoding, line_ending, path::get_canonicalized_path, shellwords::Shellwords}; -use helix_lsp::{OffsetEncoding, Url}; +use helix_core::{encoding, line_ending, shellwords::Shellwords}; use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::editor::{Action, CloseError, ConfigEvent}; use serde_json::Value; @@ -2420,66 +2419,14 @@ fn move_buffer( ensure!(args.len() == 1, format!(":move takes one argument")); let doc = doc!(cx.editor); - - let new_path = get_canonicalized_path(&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 bb61eaa6aae6..c43886570b58 100644 --- a/helix-view/src/document.rs +++ b/helix-view/src/document.rs @@ -1046,6 +1046,9 @@ impl Document { 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_core::path::get_canonicalized_path); diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs index 5a81cb5d6da8..ce0a4c315e87 100644 --- a/helix-view/src/editor.rs +++ b/helix-view/src/editor.rs @@ -22,7 +22,8 @@ use std::{ borrow::Cow, cell::Cell, collections::{BTreeMap, HashMap}, - io::stdin, + fs, + io::{self, stdin}, num::NonZeroUsize, path::{Path, PathBuf}, pin::Pin, @@ -42,6 +43,7 @@ use anyhow::{anyhow, bail, Error}; pub use helix_core::diagnostic::Severity; use helix_core::{ auto_pairs::AutoPairs, + path::get_canonicalized_path, syntax::{self, AutoPairConfig, SoftWrap}, Change, LineEnding, NATIVE_LINE_ENDING, }; @@ -1187,6 +1189,87 @@ impl Editor { 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 = get_canonicalized_path(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 calle (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); + } + /// Launch a language server for a given document fn launch_language_servers(&mut self, doc_id: DocumentId) { if !self.config().lsp.enable { @@ -1222,7 +1305,7 @@ impl Editor { .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 8b137891791f..40afa1fe3412 100644 --- a/helix-view/src/handlers/lsp.rs +++ b/helix-view/src/handlers/lsp.rs @@ -1 +1,241 @@ +use helix_lsp::{lsp, OffsetEncoding}; +use crate::editor::Action; +use crate::Editor; + +#[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 { + // 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> { + let apply_edits = |editor: &mut Editor, + 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 current_view_id = view!(editor).id; + 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_mut!(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 selections = doc.selections(); + let view_id = if selections.contains_key(¤t_view_id) { + // use current if possible + current_view_id + } else { + // Hack: we take the first available view_id + selections + .keys() + .next() + .copied() + .expect("No view_id available") + }; + + 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( + self, + &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) => { + 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(); + apply_edits( + self, + &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(self, uri, None, text_edits).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(()) + } +}