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)
pull/9386/head
Pascal Kuthe 9 months ago committed by GitHub
parent f5b67d9acb
commit 87a720c3a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,4 +1,5 @@
use crate::{ use crate::{
file_operations::FileOperationsInterest,
find_lsp_workspace, jsonrpc, find_lsp_workspace, jsonrpc,
transport::{Payload, Transport}, transport::{Payload, Transport},
Call, Error, OffsetEncoding, Result, Call, Error, OffsetEncoding, Result,
@ -9,20 +10,20 @@ use helix_loader::{self, VERSION_AND_GIT_HASH};
use helix_stdx::path; use helix_stdx::path;
use lsp::{ use lsp::{
notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport, notification::DidChangeWorkspaceFolders, CodeActionCapabilityResolveSupport,
DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, WorkspaceFolder, DidChangeWorkspaceFoldersParams, OneOf, PositionEncodingKind, SignatureHelp, Url,
WorkspaceFoldersChangeEvent, WorkspaceFolder, WorkspaceFoldersChangeEvent,
}; };
use lsp_types as lsp; use lsp_types as lsp;
use parking_lot::Mutex; use parking_lot::Mutex;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use std::future::Future;
use std::process::Stdio;
use std::sync::{ use std::sync::{
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
Arc, Arc,
}; };
use std::{collections::HashMap, path::PathBuf}; use std::{collections::HashMap, path::PathBuf};
use std::{future::Future, sync::OnceLock};
use std::{path::Path, process::Stdio};
use tokio::{ use tokio::{
io::{BufReader, BufWriter}, io::{BufReader, BufWriter},
process::{Child, Command}, process::{Child, Command},
@ -51,6 +52,7 @@ pub struct Client {
server_tx: UnboundedSender<Payload>, server_tx: UnboundedSender<Payload>,
request_counter: AtomicU64, request_counter: AtomicU64,
pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>, pub(crate) capabilities: OnceCell<lsp::ServerCapabilities>,
pub(crate) file_operation_interest: OnceLock<FileOperationsInterest>,
config: Option<Value>, config: Option<Value>,
root_path: std::path::PathBuf, root_path: std::path::PathBuf,
root_uri: Option<lsp::Url>, root_uri: Option<lsp::Url>,
@ -233,6 +235,7 @@ impl Client {
server_tx, server_tx,
request_counter: AtomicU64::new(0), request_counter: AtomicU64::new(0),
capabilities: OnceCell::new(), capabilities: OnceCell::new(),
file_operation_interest: OnceLock::new(),
config, config,
req_timeout, req_timeout,
root_path, root_path,
@ -278,6 +281,11 @@ impl Client {
.expect("language server not yet initialized!") .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 /// Client has to be initialized otherwise this function panics
#[inline] #[inline]
pub fn supports_feature(&self, feature: LanguageServerFeature) -> bool { pub fn supports_feature(&self, feature: LanguageServerFeature) -> bool {
@ -717,27 +725,27 @@ impl Client {
}) })
} }
pub fn prepare_file_rename( pub fn will_rename(
&self, &self,
old_uri: &lsp::Url, old_path: &Path,
new_uri: &lsp::Url, new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> { ) -> Option<impl Future<Output = Result<lsp::WorkspaceEdit>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.file_operations_intests();
if !capabilities.will_rename.has_interest(old_path, is_dir) {
// Return early if the server does not support willRename feature return None;
match &capabilities.workspace {
Some(workspace) => match &workspace.file_operations {
Some(op) => {
op.will_rename.as_ref()?;
}
_ => return None,
},
_ => 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 { let files = vec![lsp::FileRename {
old_uri: old_uri.to_string(), old_uri: url_from_path(old_path)?,
new_uri: new_uri.to_string(), new_uri: url_from_path(new_path)?,
}]; }];
let request = self.call_with_timeout::<lsp::request::WillRenameFiles>( let request = self.call_with_timeout::<lsp::request::WillRenameFiles>(
lsp::RenameFilesParams { files }, lsp::RenameFilesParams { files },
@ -751,27 +759,28 @@ impl Client {
}) })
} }
pub fn did_file_rename( pub fn did_rename(
&self, &self,
old_uri: &lsp::Url, old_path: &Path,
new_uri: &lsp::Url, new_path: &Path,
is_dir: bool,
) -> Option<impl Future<Output = std::result::Result<(), Error>>> { ) -> Option<impl Future<Output = std::result::Result<(), Error>>> {
let capabilities = self.capabilities.get().unwrap(); let capabilities = self.file_operations_intests();
if !capabilities.did_rename.has_interest(new_path, is_dir) {
// Return early if the server does not support DidRename feature return None;
match &capabilities.workspace {
Some(workspace) => match &workspace.file_operations {
Some(op) => {
op.did_rename.as_ref()?;
}
_ => return None,
},
_ => 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 { let files = vec![lsp::FileRename {
old_uri: old_uri.to_string(), old_uri: url_from_path(old_path)?,
new_uri: new_uri.to_string(), new_uri: url_from_path(new_path)?,
}]; }];
Some(self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files })) Some(self.notify::<lsp::notification::DidRenameFiles>(lsp::RenameFilesParams { files }))
} }

@ -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()),
}
}
}

@ -1,5 +1,6 @@
mod client; mod client;
pub mod file_event; pub mod file_event;
mod file_operations;
pub mod jsonrpc; pub mod jsonrpc;
pub mod snippet; pub mod snippet;
mod transport; mod transport;

@ -21,7 +21,6 @@ use tui::backend::Backend;
use crate::{ use crate::{
args::Args, args::Args,
commands::apply_workspace_edit,
compositor::{Compositor, Event}, compositor::{Compositor, Event},
config::Config, config::Config,
handlers, handlers,
@ -573,26 +572,8 @@ impl Application {
let lines = doc_save_event.text.len_lines(); let lines = doc_save_event.text.len_lines();
let bytes = doc_save_event.text.len_bytes(); let bytes = doc_save_event.text.len_bytes();
if doc.path() != Some(&doc_save_event.path) { self.editor
doc.set_path(Some(&doc_save_event.path)); .set_doc_path(doc_save_event.doc_id, &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);
}
// TODO: fix being overwritten by lsp // TODO: fix being overwritten by lsp
self.editor.set_status(format!( self.editor.set_status(format!(
"'{}' written, {}L {}B", "'{}' written, {}L {}B",
@ -1011,11 +992,9 @@ impl Application {
let language_server = language_server!(); let language_server = language_server!();
if language_server.is_initialized() { if language_server.is_initialized() {
let offset_encoding = language_server.offset_encoding(); let offset_encoding = language_server.offset_encoding();
let res = apply_workspace_edit( let res = self
&mut self.editor, .editor
offset_encoding, .apply_workspace_edit(offset_encoding, &params.edit);
&params.edit,
);
Ok(json!(lsp::ApplyWorkspaceEditResponse { Ok(json!(lsp::ApplyWorkspaceEditResponse {
applied: res.is_ok(), applied: res.is_ok(),

@ -726,8 +726,7 @@ pub fn code_action(cx: &mut Context) {
resolved_code_action.as_ref().unwrap_or(code_action); resolved_code_action.as_ref().unwrap_or(code_action);
if let Some(ref workspace_edit) = resolved_code_action.edit { if let Some(ref workspace_edit) = resolved_code_action.edit {
log::debug!("edit: {:?}", workspace_edit); let _ = editor.apply_workspace_edit(offset_encoding, workspace_edit);
let _ = apply_workspace_edit(editor, offset_encoding, workspace_edit);
} }
// if code action provides both edit and command first the 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)] #[derive(Debug)]
pub struct ApplyEditError { pub struct ApplyEditError {
pub kind: ApplyEditErrorKind, 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. /// Precondition: `locations` should be non-empty.
fn goto_impl( fn goto_impl(
editor: &mut Editor, editor: &mut Editor,
@ -1263,7 +1069,7 @@ pub fn rename_symbol(cx: &mut Context) {
match block_on(future) { match block_on(future) {
Ok(edits) => { 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()), Err(err) => cx.editor.set_error(err.to_string()),
} }

@ -8,7 +8,6 @@ use super::*;
use helix_core::fuzzy::fuzzy_match; use helix_core::fuzzy::fuzzy_match;
use helix_core::indent::MAX_INDENT; use helix_core::indent::MAX_INDENT;
use helix_core::{encoding, line_ending, shellwords::Shellwords}; use helix_core::{encoding, line_ending, shellwords::Shellwords};
use helix_lsp::{OffsetEncoding, Url};
use helix_view::document::DEFAULT_LANGUAGE_NAME; use helix_view::document::DEFAULT_LANGUAGE_NAME;
use helix_view::editor::{Action, CloseError, ConfigEvent}; use helix_view::editor::{Action, CloseError, ConfigEvent};
use serde_json::Value; use serde_json::Value;
@ -2404,67 +2403,14 @@ fn move_buffer(
ensure!(args.len() == 1, format!(":move takes one argument")); ensure!(args.len() == 1, format!(":move takes one argument"));
let doc = doc!(cx.editor); let doc = doc!(cx.editor);
let new_path =
helix_stdx::path::canonicalize(&PathBuf::from(args.first().unwrap().to_string()));
let old_path = doc let old_path = doc
.path() .path()
.ok_or_else(|| anyhow!("Scratch buffer cannot be moved. Use :write instead"))? .context("Scratch buffer cannot be moved. Use :write instead")?
.clone(); .clone();
let old_path_as_url = doc.url().unwrap(); let new_path = args.first().unwrap().to_string();
let new_path_as_url = Url::from_file_path(&new_path).unwrap(); if let Err(err) = cx.editor.move_path(&old_path, new_path.as_ref()) {
bail!("Could not move file: {err}");
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 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(()) Ok(())
} }

@ -1041,6 +1041,9 @@ impl Document {
self.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>) { pub fn set_path(&mut self, path: Option<&Path>) {
let path = path.map(helix_stdx::path::canonicalize); let path = path.map(helix_stdx::path::canonicalize);

@ -23,7 +23,8 @@ use std::{
borrow::Cow, borrow::Cow,
cell::Cell, cell::Cell,
collections::{BTreeMap, HashMap}, collections::{BTreeMap, HashMap},
io::stdin, fs,
io::{self, stdin},
num::NonZeroUsize, num::NonZeroUsize,
path::{Path, PathBuf}, path::{Path, PathBuf},
pin::Pin, pin::Pin,
@ -45,6 +46,7 @@ use helix_core::{
}; };
use helix_dap as dap; use helix_dap as dap;
use helix_lsp::lsp; use helix_lsp::lsp;
use helix_stdx::path::canonicalize;
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer}; use serde::{ser::SerializeMap, Deserialize, Deserializer, Serialize, Serializer};
@ -1215,6 +1217,90 @@ impl Editor {
self.launch_language_servers(doc_id) 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 /// Launch a language server for a given document
fn launch_language_servers(&mut self, doc_id: DocumentId) { fn launch_language_servers(&mut self, doc_id: DocumentId) {
if !self.config().lsp.enable { if !self.config().lsp.enable {
@ -1257,7 +1343,7 @@ impl Editor {
.collect::<HashMap<_, _>>() .collect::<HashMap<_, _>>()
}); });
if language_servers.is_empty() { if language_servers.is_empty() && doc.language_servers.is_empty() {
return; return;
} }

@ -1,4 +1,8 @@
use crate::editor::Action;
use crate::Editor;
use crate::{DocumentId, ViewId}; use crate::{DocumentId, ViewId};
use helix_lsp::util::generate_transaction_from_edits;
use helix_lsp::{lsp, OffsetEncoding};
pub enum CompletionEvent { pub enum CompletionEvent {
/// Auto completion was triggered by typing a word char /// Auto completion was triggered by typing a word char
@ -39,3 +43,228 @@ pub enum SignatureHelpEvent {
Cancel, Cancel,
RequestComplete { open: bool }, 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(())
}
}

Loading…
Cancel
Save