radicle_term/
editor.rs

1use std::ffi::OsString;
2use std::io::IsTerminal;
3use std::io::Write;
4use std::os::fd::{AsRawFd, FromRawFd};
5use std::path::{Path, PathBuf};
6use std::process;
7use std::{env, fs, io};
8
9pub const COMMENT_FILE: &str = "RAD_COMMENT";
10/// Some common paths where system-installed binaries are found.
11pub const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
12
13/// Allows for text input in the configured editor.
14pub struct Editor {
15    path: PathBuf,
16    truncate: bool,
17    cleanup: bool,
18}
19
20impl Default for Editor {
21    fn default() -> Self {
22        Self::comment()
23    }
24}
25
26impl Drop for Editor {
27    fn drop(&mut self) {
28        if self.cleanup {
29            fs::remove_file(&self.path).ok();
30        }
31    }
32}
33
34impl Editor {
35    /// Create a new editor.
36    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
37        let path = path.as_ref();
38        if path.try_exists()? {
39            let meta = fs::metadata(path)?;
40            if !meta.is_file() {
41                return Err(io::Error::new(
42                    io::ErrorKind::InvalidInput,
43                    "must be used to edit a file",
44                ));
45            }
46        }
47        Ok(Self {
48            path: path.to_path_buf(),
49            truncate: false,
50            cleanup: false,
51        })
52    }
53
54    pub fn comment() -> Self {
55        let path = env::temp_dir().join(COMMENT_FILE);
56
57        Self {
58            path,
59            truncate: true,
60            cleanup: true,
61        }
62    }
63
64    /// Set the file extension.
65    pub fn extension(mut self, ext: &str) -> Self {
66        let ext = ext.trim_start_matches('.');
67
68        self.path.set_extension(ext);
69        self
70    }
71
72    /// Truncate the file to length 0 when opening
73    pub fn truncate(mut self, truncate: bool) -> Self {
74        self.truncate = truncate;
75        self
76    }
77
78    /// Clean up the file after the [`Editor`] is dropped.
79    pub fn cleanup(mut self, cleanup: bool) -> Self {
80        self.cleanup = cleanup;
81        self
82    }
83
84    /// Initialize the file with the provided `content`, as long as the file
85    /// does not already contain anything.
86    pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
87        let content = content.as_ref();
88        let mut file = fs::OpenOptions::new()
89            .write(true)
90            .create(true)
91            .truncate(self.truncate)
92            .open(&self.path)?;
93
94        if file.metadata()?.len() == 0 {
95            file.write_all(content)?;
96            if !content.ends_with(&[b'\n']) {
97                file.write_all(b"\n")?;
98            }
99            file.flush()?;
100        }
101        Ok(self)
102    }
103
104    /// Open the editor and return the edited text.
105    ///
106    /// If the text hasn't changed from the initial contents of the editor,
107    /// return `None`.
108    pub fn edit(&mut self) -> io::Result<Option<String>> {
109        let Some(cmd) = self::default_editor() else {
110            return Err(io::Error::new(
111                io::ErrorKind::NotFound,
112                "editor not configured: the `EDITOR` environment variable is not set",
113            ));
114        };
115        let Some(parts) = shlex::split(cmd.to_string_lossy().as_ref()) else {
116            return Err(io::Error::new(
117                io::ErrorKind::InvalidInput,
118                format!("invalid editor command {cmd:?}"),
119            ));
120        };
121        let Some((program, args)) = parts.split_first() else {
122            return Err(io::Error::new(
123                io::ErrorKind::InvalidInput,
124                format!("invalid editor command {cmd:?}"),
125            ));
126        };
127
128        // We duplicate the stderr file descriptor to pass it to the child process, otherwise, if
129        // we simply pass the `RawFd` of our stderr, `Command` will close our stderr when the
130        // child exits.
131        let stderr = io::stderr().as_raw_fd();
132        let stderr = unsafe { libc::dup(stderr) };
133        let stdin = if io::stdin().is_terminal() {
134            process::Stdio::inherit()
135        } else {
136            let tty = termion::get_tty()?;
137            // If standard input is not a terminal device, the editor won't work correctly.
138            // In that case, we use the terminal device, eg. `/dev/tty` as standard input.
139            process::Stdio::from(tty)
140        };
141
142        process::Command::new(program)
143            .stdout(unsafe { process::Stdio::from_raw_fd(stderr) })
144            .stderr(process::Stdio::inherit())
145            .stdin(stdin)
146            .args(args)
147            .arg(&self.path)
148            .spawn()
149            .map_err(|e| {
150                io::Error::new(
151                    e.kind(),
152                    format!("failed to spawn editor command {cmd:?}: {e}"),
153                )
154            })?
155            .wait()
156            .map_err(|e| {
157                io::Error::new(
158                    e.kind(),
159                    format!("editor command {cmd:?} didn't spawn: {e}"),
160                )
161            })?;
162
163        let text = fs::read_to_string(&self.path)?;
164        if text.trim().is_empty() {
165            return Ok(None);
166        }
167        Ok(Some(text))
168    }
169}
170
171/// Get the default editor command.
172fn default_editor() -> Option<OsString> {
173    // First check the standard environment variables.
174    if let Ok(visual) = env::var("VISUAL") {
175        if !visual.is_empty() {
176            return Some(visual.into());
177        }
178    }
179    if let Ok(editor) = env::var("EDITOR") {
180        if !editor.is_empty() {
181            return Some(editor.into());
182        }
183    }
184    // Check Git. The user might have configured their editor there.
185    #[cfg(feature = "git2")]
186    if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
187        return Some(path.into_os_string());
188    }
189    // On macOS, `nano` is installed by default and it's what most users are used to
190    // in the terminal.
191    if cfg!(target_os = "macos") && exists("nano") {
192        return Some("nano".into());
193    }
194    // If all else fails, we try `vi`. It's usually installed on most unix-based systems.
195    if exists("vi") {
196        return Some("vi".into());
197    }
198    None
199}
200
201/// Check whether a binary can be found in the most common paths.
202/// We don't bother checking the $PATH variable, as we're only looking for very standard tools
203/// and prefer not to make this too complex.
204fn exists(cmd: &str) -> bool {
205    for dir in PATHS {
206        if Path::new(dir).join(cmd).exists() {
207            return true;
208        }
209    }
210    false
211}