aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPascal Kuthe2024-01-28 16:34:45 +0000
committerGitHub2024-01-28 16:34:45 +0000
commit87a720c3a13ccc7245f5b0befc008db5bd039032 (patch)
treee0e3f91c516a10d154cd01861e96ae0f50ea3cad
parentf5b67d9acb89ea54e7226111e3e4d8c3a008144b (diff)
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 <path>` benefits from similar fixed (but without didRename)
-rw-r--r--helix-lsp/src/client.rs83
-rw-r--r--helix-lsp/src/file_operations.rs105
-rw-r--r--helix-lsp/src/lib.rs1
-rw-r--r--helix-term/src/application.rs31
-rw-r--r--helix-term/src/commands/lsp.rs198
-rw-r--r--helix-term/src/commands/typed.rs62
-rw-r--r--helix-view/src/document.rs3
-rw-r--r--helix-view/src/editor.rs90
-rw-r--r--helix-view/src/handlers/lsp.rs229
9 files changed, 483 insertions, 319 deletions
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_loader::{self, VERSION_AND_GIT_HASH};
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<Payload>,
request_counter: AtomicU64,
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
+ pub(crate) file_operation_interest: OnceLock<FileOperationsInterest>,
config: Option<Value>,
root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>,
@@ -233,6 +235,7 @@ impl Client {
server_tx,
request_counter: AtomicU64::new(0),
capabilities: OnceCell::new(),
+ file_operation_interest: OnceLock::new(),
config,
req_timeout,
root_path,
@@ -278,6 +281,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 {
@@ -717,27 +725,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<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
- 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::request::WillRenameFiles>(
lsp::RenameFilesParams { files },
@@ -751,27 +759,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<impl Future<Output = std::result::Result<(), Error>>> {
- 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::notification::DidRenameFiles>(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 tui::backend::Backend;
use crate::{
args::Args,
- commands::apply_workspace_edit,
compositor::{Compositor, Event},
config::Config,
handlers,
@@ -573,26 +572,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);
- // 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 @@ 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,
- &params.edit,
- );
+ let res = self
+ .editor
+ .apply_workspace_edit(offset_encoding, &params.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 @@ impl ToString for ApplyEditErrorKind {
}
}
-///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<i32>,
- text_edits: Vec<lsp::TextEdit>|
- -> 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 @@ 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 81ffdf87..b7ceeba5 100644
--- a/helix-term/src/commands/typed.rs
+++ b/helix-term/src/commands/typed.rs
@@ -8,7 +8,6 @@ use super::*;
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<helix_lsp::lsp::WorkspaceEdit>,
- 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 @@ 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_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 @@ use std::{
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_core::{
};
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 @@ 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 = 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 @@ impl Editor {
.collect::<HashMap<_, _>>()
});
- 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<i32>,
+ text_edits: Vec<lsp::TextEdit>,
+ 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(())
+ }
+}