aboutsummaryrefslogtreecommitdiff
path: root/helix-view
diff options
context:
space:
mode:
authorPascal Kuthe2024-01-28 16:34:45 +0000
committerGitHub2024-01-28 16:34:45 +0000
commit87a720c3a13ccc7245f5b0befc008db5bd039032 (patch)
treee0e3f91c516a10d154cd01861e96ae0f50ea3cad /helix-view
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)
Diffstat (limited to 'helix-view')
-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
3 files changed, 320 insertions, 2 deletions
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(())
+ }
+}