Apply file-system operations coming from an LSP code action

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

crates/editor/src/editor.rs   |   7 
crates/project/src/fs.rs      | 250 +++++++++++++++++++++++++++++++++---
crates/project/src/project.rs |  95 +++++++++++++
crates/server/src/rpc.rs      |  12 +
crates/zed/src/zed.rs         |   7 
5 files changed, 334 insertions(+), 37 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -2077,8 +2077,13 @@ impl Editor {
             Some((buffer, action))
         })?;
 
-        Some(workspace.project().update(cx, |project, cx| {
+        let apply_code_actions = workspace.project().update(cx, |project, cx| {
             project.apply_code_action(buffer, action, cx)
+        });
+        Some(cx.spawn(|workspace, cx| async move {
+            let buffers = apply_code_actions.await?;
+
+            Ok(())
         }))
     }
 

crates/project/src/fs.rs 🔗

@@ -13,6 +13,11 @@ use text::Rope;
 
 #[async_trait::async_trait]
 pub trait Fs: Send + Sync {
+    async fn create_dir(&self, path: &Path) -> Result<()>;
+    async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()>;
+    async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()>;
+    async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()>;
+    async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>;
     async fn load(&self, path: &Path) -> Result<String>;
     async fn save(&self, path: &Path, text: &Rope) -> Result<()>;
     async fn canonicalize(&self, path: &Path) -> Result<PathBuf>;
@@ -32,6 +37,24 @@ pub trait Fs: Send + Sync {
     fn as_fake(&self) -> &FakeFs;
 }
 
+#[derive(Copy, Clone, Default)]
+pub struct CreateOptions {
+    pub overwrite: bool,
+    pub ignore_if_exists: bool,
+}
+
+#[derive(Copy, Clone, Default)]
+pub struct RenameOptions {
+    pub overwrite: bool,
+    pub ignore_if_exists: bool,
+}
+
+#[derive(Copy, Clone, Default)]
+pub struct RemoveOptions {
+    pub recursive: bool,
+    pub ignore_if_not_exists: bool,
+}
+
 #[derive(Clone, Debug)]
 pub struct Metadata {
     pub inode: u64,
@@ -44,6 +67,60 @@ pub struct RealFs;
 
 #[async_trait::async_trait]
 impl Fs for RealFs {
+    async fn create_dir(&self, path: &Path) -> Result<()> {
+        Ok(smol::fs::create_dir_all(path).await?)
+    }
+
+    async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
+        let mut open_options = smol::fs::OpenOptions::new();
+        open_options.create(true);
+        if options.overwrite {
+            open_options.truncate(true);
+        } else if !options.ignore_if_exists {
+            open_options.create_new(true);
+        }
+        open_options.open(path).await?;
+        Ok(())
+    }
+
+    async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> {
+        if !options.overwrite && smol::fs::metadata(target).await.is_ok() {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        smol::fs::rename(source, target).await?;
+        Ok(())
+    }
+
+    async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+        let result = if options.recursive {
+            smol::fs::remove_dir_all(path).await
+        } else {
+            smol::fs::remove_dir(path).await
+        };
+        match result {
+            Ok(()) => Ok(()),
+            Err(err) if err.kind() == io::ErrorKind::NotFound && options.ignore_if_not_exists => {
+                Ok(())
+            }
+            Err(err) => Err(err)?,
+        }
+    }
+
+    async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+        match smol::fs::remove_file(path).await {
+            Ok(()) => Ok(()),
+            Err(err) if err.kind() == io::ErrorKind::NotFound && options.ignore_if_not_exists => {
+                Ok(())
+            }
+            Err(err) => Err(err)?,
+        }
+    }
+
     async fn load(&self, path: &Path) -> Result<String> {
         let mut file = smol::fs::File::open(path).await?;
         let mut text = String::new();
@@ -162,15 +239,19 @@ impl FakeFsState {
         }
     }
 
-    async fn emit_event(&mut self, paths: &[&Path]) {
+    async fn emit_event<I, T>(&mut self, paths: I)
+    where
+        I: IntoIterator<Item = T>,
+        T: Into<PathBuf>,
+    {
         use postage::prelude::Sink as _;
 
         let events = paths
-            .iter()
+            .into_iter()
             .map(|path| fsevent::Event {
                 event_id: 0,
                 flags: fsevent::StreamFlags::empty(),
-                path: path.to_path_buf(),
+                path: path.into(),
             })
             .collect();
 
@@ -292,46 +373,163 @@ impl FakeFs {
         }
         .boxed()
     }
+}
 
-    pub async fn remove(&self, path: &Path) -> Result<()> {
+#[cfg(any(test, feature = "test-support"))]
+#[async_trait::async_trait]
+impl Fs for FakeFs {
+    async fn create_dir(&self, path: &Path) -> Result<()> {
+        self.executor.simulate_random_delay().await;
+        let state = &mut *self.state.lock().await;
+        let mut ancestor_path = PathBuf::new();
+        let mut created_dir_paths = Vec::new();
+        for component in path.components() {
+            ancestor_path.push(component);
+            let entry = state
+                .entries
+                .entry(ancestor_path.clone())
+                .or_insert_with(|| {
+                    let inode = state.next_inode;
+                    state.next_inode += 1;
+                    created_dir_paths.push(ancestor_path.clone());
+                    FakeFsEntry {
+                        metadata: Metadata {
+                            inode,
+                            mtime: SystemTime::now(),
+                            is_dir: true,
+                            is_symlink: false,
+                        },
+                        content: None,
+                    }
+                });
+            if !entry.metadata.is_dir {
+                return Err(anyhow!(
+                    "cannot create directory because {:?} is a file",
+                    ancestor_path
+                ));
+            }
+        }
+        state.emit_event(&created_dir_paths).await;
+
+        Ok(())
+    }
+
+    async fn create_file(&self, path: &Path, options: CreateOptions) -> Result<()> {
+        self.executor.simulate_random_delay().await;
         let mut state = self.state.lock().await;
         state.validate_path(path)?;
-        state.entries.retain(|path, _| !path.starts_with(path));
+        if let Some(entry) = state.entries.get_mut(path) {
+            if entry.metadata.is_dir || entry.metadata.is_symlink {
+                return Err(anyhow!(
+                    "cannot create file because {:?} is a dir or a symlink",
+                    path
+                ));
+            }
+
+            if options.overwrite {
+                entry.metadata.mtime = SystemTime::now();
+                entry.content = Some(Default::default());
+            } else if !options.ignore_if_exists {
+                return Err(anyhow!(
+                    "cannot create file because {:?} already exists",
+                    path
+                ));
+            }
+        } else {
+            let inode = state.next_inode;
+            state.next_inode += 1;
+            let entry = FakeFsEntry {
+                metadata: Metadata {
+                    inode,
+                    mtime: SystemTime::now(),
+                    is_dir: false,
+                    is_symlink: false,
+                },
+                content: Some(Default::default()),
+            };
+            state.entries.insert(path.to_path_buf(), entry);
+        }
         state.emit_event(&[path]).await;
+
         Ok(())
     }
 
-    pub async fn rename(&self, source: &Path, target: &Path) -> Result<()> {
+    async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> Result<()> {
         let mut state = self.state.lock().await;
         state.validate_path(source)?;
         state.validate_path(target)?;
-        if state.entries.contains_key(target) {
-            Err(anyhow!("target path already exists"))
-        } else {
-            let mut removed = Vec::new();
-            state.entries.retain(|path, entry| {
-                if let Ok(relative_path) = path.strip_prefix(source) {
-                    removed.push((relative_path.to_path_buf(), entry.clone()));
-                    false
-                } else {
-                    true
+
+        if !options.overwrite && state.entries.contains_key(target) {
+            if options.ignore_if_exists {
+                return Ok(());
+            } else {
+                return Err(anyhow!("{target:?} already exists"));
+            }
+        }
+
+        let mut removed = Vec::new();
+        state.entries.retain(|path, entry| {
+            if let Ok(relative_path) = path.strip_prefix(source) {
+                removed.push((relative_path.to_path_buf(), entry.clone()));
+                false
+            } else {
+                true
+            }
+        });
+
+        for (relative_path, entry) in removed {
+            let new_path = target.join(relative_path);
+            state.entries.insert(new_path, entry);
+        }
+
+        state.emit_event(&[source, target]).await;
+        Ok(())
+    }
+
+    async fn remove_dir(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+        let mut state = self.state.lock().await;
+        state.validate_path(path)?;
+        if let Some(entry) = state.entries.get(path) {
+            if !entry.metadata.is_dir {
+                return Err(anyhow!("cannot remove {path:?} because it is not a dir"));
+            }
+
+            if !options.recursive {
+                let descendants = state
+                    .entries
+                    .keys()
+                    .filter(|path| path.starts_with(path))
+                    .count();
+                if descendants > 1 {
+                    return Err(anyhow!("{path:?} is not empty"));
                 }
-            });
+            }
+
+            state.entries.retain(|path, _| !path.starts_with(path));
+            state.emit_event(&[path]).await;
+        } else if !options.ignore_if_not_exists {
+            return Err(anyhow!("{path:?} does not exist"));
+        }
 
-            for (relative_path, entry) in removed {
-                let new_path = target.join(relative_path);
-                state.entries.insert(new_path, entry);
+        Ok(())
+    }
+
+    async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()> {
+        let mut state = self.state.lock().await;
+        state.validate_path(path)?;
+        if let Some(entry) = state.entries.get(path) {
+            if entry.metadata.is_dir {
+                return Err(anyhow!("cannot remove {path:?} because it is not a file"));
             }
 
-            state.emit_event(&[source, target]).await;
-            Ok(())
+            state.entries.remove(path);
+            state.emit_event(&[path]).await;
+        } else if !options.ignore_if_not_exists {
+            return Err(anyhow!("{path:?} does not exist"));
         }
+        Ok(())
     }
-}
 
-#[cfg(any(test, feature = "test-support"))]
-#[async_trait::async_trait]
-impl Fs for FakeFs {
     async fn load(&self, path: &Path) -> Result<String> {
         self.executor.simulate_random_delay().await;
         let state = self.state.lock().await;

crates/project/src/project.rs 🔗

@@ -1156,7 +1156,7 @@ impl Project {
         buffer: ModelHandle<Buffer>,
         mut action: CodeAction<language::Anchor>,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Result<()>> {
+    ) -> Task<Result<HashMap<ModelHandle<Buffer>, Vec<Range<language::Anchor>>>>> {
         if self.is_local() {
             let buffer = buffer.read(cx);
             let server = if let Some(language_server) = buffer.language_server() {
@@ -1165,6 +1165,7 @@ impl Project {
                 return Task::ready(Ok(Default::default()));
             };
             let position = action.position.to_point_utf16(buffer).to_lsp_position();
+            let fs = self.fs.clone();
 
             cx.spawn(|this, mut cx| async move {
                 let range = action
@@ -1178,9 +1179,68 @@ impl Project {
                 let action = server
                     .request::<lsp::request::CodeActionResolveRequest>(action.lsp_action)
                     .await?;
-                let edit = action
-                    .edit
-                    .ok_or_else(|| anyhow!("code action has no edit"));
+
+                let mut operations = Vec::new();
+                match action.edit.and_then(|e| e.document_changes) {
+                    Some(lsp::DocumentChanges::Edits(edits)) => {
+                        operations.extend(edits.into_iter().map(lsp::DocumentChangeOperation::Edit))
+                    }
+                    Some(lsp::DocumentChanges::Operations(ops)) => operations = ops,
+                    None => {}
+                }
+
+                for operation in operations {
+                    match operation {
+                        lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Create(op)) => {
+                            let path = op
+                                .uri
+                                .to_file_path()
+                                .map_err(|_| anyhow!("can't convert URI to path"))?;
+
+                            if let Some(parent_path) = path.parent() {
+                                fs.create_dir(parent_path).await?;
+                            }
+                            if path.ends_with("/") {
+                                fs.create_dir(&path).await?;
+                            } else {
+                                fs.create_file(
+                                    &path,
+                                    op.options.map(Into::into).unwrap_or_default(),
+                                )
+                                .await?;
+                            }
+                        }
+                        lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Rename(op)) => {
+                            let source = op
+                                .old_uri
+                                .to_file_path()
+                                .map_err(|_| anyhow!("can't convert URI to path"))?;
+                            let target = op
+                                .new_uri
+                                .to_file_path()
+                                .map_err(|_| anyhow!("can't convert URI to path"))?;
+                            fs.rename(
+                                &source,
+                                &target,
+                                op.options.map(Into::into).unwrap_or_default(),
+                            )
+                            .await?;
+                        }
+                        lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Delete(op)) => {
+                            let path = op
+                                .uri
+                                .to_file_path()
+                                .map_err(|_| anyhow!("can't convert URI to path"))?;
+                            let options = op.options.map(Into::into).unwrap_or_default();
+                            if path.ends_with("/") {
+                                fs.remove_dir(&path, options).await?;
+                            } else {
+                                fs.remove_file(&path, options).await?;
+                            }
+                        }
+                        lsp::DocumentChangeOperation::Edit(edit) => todo!(),
+                    }
+                }
                 // match edit {
                 //     Ok(edit) => edit.,
                 //     Err(_) => todo!(),
@@ -2263,6 +2323,33 @@ impl<P: AsRef<Path>> From<(WorktreeId, P)> for ProjectPath {
     }
 }
 
+impl From<lsp::CreateFileOptions> for fs::CreateOptions {
+    fn from(options: lsp::CreateFileOptions) -> Self {
+        Self {
+            overwrite: options.overwrite.unwrap_or(false),
+            ignore_if_exists: options.ignore_if_exists.unwrap_or(false),
+        }
+    }
+}
+
+impl From<lsp::RenameFileOptions> for fs::RenameOptions {
+    fn from(options: lsp::RenameFileOptions) -> Self {
+        Self {
+            overwrite: options.overwrite.unwrap_or(false),
+            ignore_if_exists: options.ignore_if_exists.unwrap_or(false),
+        }
+    }
+}
+
+impl From<lsp::DeleteFileOptions> for fs::RemoveOptions {
+    fn from(options: lsp::DeleteFileOptions) -> Self {
+        Self {
+            recursive: options.recursive.unwrap_or(false),
+            ignore_if_not_exists: options.ignore_if_not_exists.unwrap_or(false),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::{Event, *};

crates/server/src/rpc.rs 🔗

@@ -1607,10 +1607,14 @@ mod tests {
         buffer_c.condition(&cx_c, |buf, _| !buf.is_dirty()).await;
 
         // Make changes on host's file system, see those changes on guest worktrees.
-        fs.rename("/a/file1".as_ref(), "/a/file1-renamed".as_ref())
-            .await
-            .unwrap();
-        fs.rename("/a/file2".as_ref(), "/a/file3".as_ref())
+        fs.rename(
+            "/a/file1".as_ref(),
+            "/a/file1-renamed".as_ref(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+        fs.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
             .await
             .unwrap();
         fs.insert_file(Path::new("/a/file4"), "4".into())

crates/zed/src/zed.rs 🔗

@@ -126,7 +126,7 @@ mod tests {
     use super::*;
     use editor::{DisplayPoint, Editor};
     use gpui::{MutableAppContext, TestAppContext, ViewHandle};
-    use project::ProjectPath;
+    use project::{Fs, ProjectPath};
     use serde_json::json;
     use std::{
         collections::HashSet,
@@ -817,7 +817,10 @@ mod tests {
                     .active_pane()
                     .update(cx, |pane, cx| pane.close_item(editor2.id(), cx));
                 drop(editor2);
-                app_state.fs.as_fake().remove(Path::new("/root/a/file2"))
+                app_state
+                    .fs
+                    .as_fake()
+                    .remove_file(Path::new("/root/a/file2"), Default::default())
             })
             .await
             .unwrap();