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)
This commit is contained in:
parent
f5b67d9acb
commit
87a720c3a1
9 changed files with 483 additions and 319 deletions
|
@ -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()?;
|
||||
let capabilities = self.file_operations_intests();
|
||||
if !capabilities.will_rename.has_interest(old_path, is_dir) {
|
||||
return None;
|
||||
}
|
||||
_ => 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 {
|
||||
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 }))
|
||||
}
|
||||
|
|
105
helix-lsp/src/file_operations.rs
Normal file
105
helix-lsp/src/file_operations.rs
Normal file
|
@ -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;
|
||||
pub mod file_event;
|
||||
mod file_operations;
|
||||
pub mod jsonrpc;
|
||||
pub mod snippet;
|
||||
mod transport;
|
||||
|
|
|
@ -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,
|
||||
¶ms.edit,
|
||||
);
|
||||
let res = self
|
||||
.editor
|
||||
.apply_workspace_edit(offset_encoding, ¶ms.edit);
|
||||
|
||||
Ok(json!(lsp::ApplyWorkspaceEditResponse {
|
||||
applied: res.is_ok(),
|
||||
|
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
};
|
||||
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}");
|
||||
}
|
||||
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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue