fm/modes/utils/
shell_escape.rs

1use std::{borrow::Cow, ffi::OsStr};
2
3use anyhow::Result;
4
5/// Quote a string for shell insertion.
6/// It is required to create commands like "zsh -c nvim path" where `<path>` may contain any kind of byte.
7/// We only have problems with `'`, `"` and `\'.
8/// Since it's the only usage, we don't really care about allocation.
9pub trait Quote<S> {
10    /// Shell quote a filepath to use it in _shell_ commands.
11    /// It's the responsability of the caller to ensure this trait is only used for
12    /// commands **executed by the shell itself**.
13    /// Using it for normal commands isn't necessary and will create errors.
14    ///
15    /// # Errors
16    ///
17    /// It may fail if the string can't be read as an utf-8 string.
18    /// For the most common case `(&str, Cow<str>, String)` it's impossible.
19    fn quote(&self) -> Result<S>;
20}
21
22impl Quote<String> for String {
23    fn quote(&self) -> Result<String> {
24        self.as_str().quote()
25    }
26}
27
28impl Quote<String> for &str {
29    fn quote(&self) -> Result<String> {
30        try_quote(self)
31    }
32}
33
34impl Quote<String> for Cow<'_, str> {
35    fn quote(&self) -> Result<String> {
36        try_quote(self)
37    }
38}
39
40impl Quote<String> for &OsStr {
41    fn quote(&self) -> Result<String> {
42        self.to_string_lossy().quote()
43    }
44}
45
46fn must_quote(byte: u8) -> bool {
47    matches!(byte, b' ' | b'\'' | b'"')
48}
49
50/// Quote a path to insert it into a shell command if need be.
51/// Inspired by [yazi](https://github.com/sxyazi/yazi/blob/main/yazi-shared/src/shell/unix.rs)
52fn try_quote(s: &str) -> Result<String> {
53    if !s.bytes().any(must_quote) {
54        Ok(s.into())
55    } else {
56        let len = s.len();
57        let mut escaped = Vec::with_capacity(len + 2);
58        escaped.push(b'\'');
59        for byte in s.bytes() {
60            match byte {
61                b'\'' | b'"' => {
62                    escaped.reserve(4);
63                    escaped.push(b'\'');
64                    escaped.push(b'\\');
65                    escaped.push(byte);
66                    escaped.push(b'\'');
67                }
68                _ => escaped.push(byte),
69            }
70        }
71        escaped.push(b'\'');
72        let s = String::from_utf8(escaped)?;
73        crate::log_info!("try quote: #{s}#");
74        Ok(s)
75    }
76}
77
78/// Used to shell quote every filepath of a vector of string.
79pub trait JoinQuote {
80    fn join_quote(&self, sep: &str) -> String;
81}
82
83impl JoinQuote for &Vec<String> {
84    /// Quote every _filepath_ in the vector and join them.
85    fn join_quote(&self, sep: &str) -> String {
86        self.iter()
87            .filter_map(|fp| fp.quote().ok())
88            .collect::<Vec<_>>()
89            .join(sep)
90    }
91}
92
93/// Returns the received command with those files quoted and appended. If no file were provided
94/// (if `files` is `None`), it returns the shell command it self.
95pub fn append_files_to_shell_command(shell_command: String, files: Option<Vec<String>>) -> String {
96    let Some(files) = &files else {
97        return shell_command;
98    };
99    shell_command + " " + &files.join_quote(" ")
100}