cli: Allow opening non-existent paths (#43250)

Ulysse Buonomo and Syed Sadiq Ali created

Changes are made to `parse_path_with_position`:
we try to get the canonical, existing parts of
a path, then append the non-existing parts.

Closes #4441

Release Notes:

- Added the possibility to open a non-existing path using `zed` CLI
  ```
  zed path/to/non/existing/file.txt
  ```

Co-authored-by: Syed Sadiq Ali <sadiqonemail@gmail.com>

Change summary

Cargo.lock             |   1 
crates/cli/Cargo.toml  |   4 
crates/cli/src/main.rs | 187 ++++++++++++++++++++++++++++++++++++++-----
crates/zlog/README.md  |  15 +++
script/debug-cli       |   2 
5 files changed, 185 insertions(+), 24 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -3086,6 +3086,7 @@ dependencies = [
  "rayon",
  "release_channel",
  "serde",
+ "serde_json",
  "tempfile",
  "util",
  "windows 0.61.3",

crates/cli/Cargo.toml 🔗

@@ -34,6 +34,10 @@ util.workspace = true
 tempfile.workspace = true
 rayon.workspace = true
 
+[dev-dependencies]
+serde_json.workspace = true
+util = { workspace = true, features = ["test-support"] }
+
 [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
 exec.workspace = true
 fork.workspace = true

crates/cli/src/main.rs 🔗

@@ -129,32 +129,173 @@ struct Args {
     askpass: Option<String>,
 }
 
+/// Parses a path containing a position (e.g. `path:line:column`)
+/// and returns its canonicalized string representation.
+///
+/// If a part of path doesn't exist, it will canonicalize the
+/// existing part and append the non-existing part.
+///
+/// This method must return an absolute path, as many zed
+/// crates assume absolute paths.
 fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
-    let canonicalized = match Path::new(argument_str).canonicalize() {
-        Ok(existing_path) => PathWithPosition::from_path(existing_path),
-        Err(_) => {
-            let path = PathWithPosition::parse_str(argument_str);
+    match Path::new(argument_str).canonicalize() {
+        Ok(existing_path) => Ok(PathWithPosition::from_path(existing_path)),
+        Err(_) => PathWithPosition::parse_str(argument_str).map_path(|mut path| {
             let curdir = env::current_dir().context("retrieving current directory")?;
-            path.map_path(|path| match fs::canonicalize(&path) {
-                Ok(path) => Ok(path),
-                Err(e) => {
-                    if let Some(mut parent) = path.parent() {
-                        if parent == Path::new("") {
-                            parent = &curdir
-                        }
-                        match fs::canonicalize(parent) {
-                            Ok(parent) => Ok(parent.join(path.file_name().unwrap())),
-                            Err(_) => Err(e),
-                        }
-                    } else {
-                        Err(e)
-                    }
+            let mut children = Vec::new();
+            let root;
+            loop {
+                // canonicalize handles './', and '/'.
+                if let Ok(canonicalized) = fs::canonicalize(&path) {
+                    root = canonicalized;
+                    break;
                 }
-            })
-        }
-        .with_context(|| format!("parsing as path with position {argument_str}"))?,
-    };
-    Ok(canonicalized.to_string(|path| path.to_string_lossy().into_owned()))
+                // The comparison to `curdir` is just a shortcut
+                // since we know it is canonical. The other one
+                // is if `argument_str` is a string that starts
+                // with a name (e.g. "foo/bar").
+                if path == curdir || path == Path::new("") {
+                    root = curdir;
+                    break;
+                }
+                children.push(
+                    path.file_name()
+                        .with_context(|| format!("parsing as path with position {argument_str}"))?
+                        .to_owned(),
+                );
+                if !path.pop() {
+                    unreachable!("parsing as path with position {argument_str}");
+                }
+            }
+            Ok(children.iter().rev().fold(root, |mut path, child| {
+                path.push(child);
+                path
+            }))
+        }),
+    }
+    .map(|path_with_pos| path_with_pos.to_string(|path| path.to_string_lossy().into_owned()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_json::json;
+    use util::path;
+    use util::paths::SanitizedPath;
+    use util::test::TempTree;
+
+    macro_rules! assert_path_eq {
+        ($left:expr, $right:expr) => {
+            assert_eq!(
+                SanitizedPath::new(Path::new(&$left)),
+                SanitizedPath::new(Path::new(&$right))
+            )
+        };
+    }
+
+    fn cwd() -> PathBuf {
+        env::current_dir().unwrap()
+    }
+
+    static CWD_LOCK: Mutex<()> = Mutex::new(());
+
+    fn with_cwd<T>(path: &Path, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
+        let _lock = CWD_LOCK.lock();
+        let old_cwd = cwd();
+        env::set_current_dir(path)?;
+        let result = f();
+        env::set_current_dir(old_cwd)?;
+        result
+    }
+
+    #[test]
+    fn test_parse_non_existing_path() {
+        // Absolute path
+        let result = parse_path_with_position(path!("/non/existing/path.txt")).unwrap();
+        assert_path_eq!(result, path!("/non/existing/path.txt"));
+
+        // Absolute path in cwd
+        let path = cwd().join(path!("non/existing/path.txt"));
+        let expected = path.to_string_lossy().to_string();
+        let result = parse_path_with_position(&expected).unwrap();
+        assert_path_eq!(result, expected);
+
+        // Relative path
+        let result = parse_path_with_position(path!("non/existing/path.txt")).unwrap();
+        assert_path_eq!(result, expected)
+    }
+
+    #[test]
+    fn test_parse_existing_path() {
+        let temp_tree = TempTree::new(json!({
+            "file.txt": "",
+        }));
+        let file_path = temp_tree.path().join("file.txt");
+        let expected = file_path.to_string_lossy().to_string();
+
+        // Absolute path
+        let result = parse_path_with_position(file_path.to_str().unwrap()).unwrap();
+        assert_path_eq!(result, expected);
+
+        // Relative path
+        let result = with_cwd(temp_tree.path(), || parse_path_with_position("file.txt")).unwrap();
+        assert_path_eq!(result, expected);
+    }
+
+    // NOTE:
+    // While POSIX symbolic links are somewhat supported on Windows, they are an opt in by the user, and thus
+    // we assume that they are not supported out of the box.
+    #[cfg(not(windows))]
+    #[test]
+    fn test_parse_symlink_file() {
+        let temp_tree = TempTree::new(json!({
+            "target.txt": "",
+        }));
+        let target_path = temp_tree.path().join("target.txt");
+        let symlink_path = temp_tree.path().join("symlink.txt");
+        std::os::unix::fs::symlink(&target_path, &symlink_path).unwrap();
+
+        // Absolute path
+        let result = parse_path_with_position(symlink_path.to_str().unwrap()).unwrap();
+        assert_eq!(result, target_path.to_string_lossy());
+
+        // Relative path
+        let result =
+            with_cwd(temp_tree.path(), || parse_path_with_position("symlink.txt")).unwrap();
+        assert_eq!(result, target_path.to_string_lossy());
+    }
+
+    #[cfg(not(windows))]
+    #[test]
+    fn test_parse_symlink_dir() {
+        let temp_tree = TempTree::new(json!({
+            "some": {
+                "dir": { // symlink target
+                    "ec": {
+                        "tory": {
+                            "file.txt": "",
+        }}}}}));
+
+        let target_file_path = temp_tree.path().join("some/dir/ec/tory/file.txt");
+        let expected = target_file_path.to_string_lossy();
+
+        let dir_path = temp_tree.path().join("some/dir");
+        let symlink_path = temp_tree.path().join("symlink");
+        std::os::unix::fs::symlink(&dir_path, &symlink_path).unwrap();
+
+        // Absolute path
+        let result =
+            parse_path_with_position(symlink_path.join("ec/tory/file.txt").to_str().unwrap())
+                .unwrap();
+        assert_eq!(result, expected);
+
+        // Relative path
+        let result = with_cwd(temp_tree.path(), || {
+            parse_path_with_position("symlink/ec/tory/file.txt")
+        })
+        .unwrap();
+        assert_eq!(result, expected);
+    }
 }
 
 fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {

crates/zlog/README.md 🔗

@@ -0,0 +1,15 @@
+# Zlog
+
+Use the `ZED_LOG` environment variable to control logging output for Zed
+applications and libraries. The variable accepts a comma-separated list of
+directives that specify logging levels for different modules (crates). The
+general format is for instance:
+
+```
+ZED_LOG=info,project=debug,agent=off
+```
+
+- Levels can be one of: `off`/`none`, `error`, `warn`, `info`, `debug`, or
+  `trace`.
+- You don't need to specify the global level, default is `trace` in the crate
+  and `info` set by `RUST_LOG` in Zed.

script/debug-cli 🔗

@@ -1,3 +1,3 @@
 #!/usr/bin/env bash
 
-cargo build; cargo run -p cli -- --foreground --zed=target/debug/zed "$@"
+cargo build -p zed && cargo run -p cli -- --foreground --zed=${CARGO_TARGET_DIR:-target}/debug/zed "$@"