Skip to main content

radicle_term/
editor.rs

1use std::ffi::OsString;
2use std::io::IsTerminal;
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process;
6use std::{env, fs, io};
7
8/// Allows for text input in the configured editor.
9pub struct Editor {
10    path: PathBuf,
11    truncate: bool,
12    cleanup: bool,
13}
14
15impl Default for Editor {
16    fn default() -> Self {
17        Self::comment()
18    }
19}
20
21impl Drop for Editor {
22    fn drop(&mut self) {
23        if self.cleanup {
24            fs::remove_file(&self.path).ok();
25        }
26    }
27}
28
29impl Editor {
30    /// Create a new editor.
31    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
32        let path = path.as_ref();
33        if path.try_exists()? {
34            let meta = fs::metadata(path)?;
35            if !meta.is_file() {
36                return Err(io::Error::new(
37                    io::ErrorKind::InvalidInput,
38                    "must be used to edit a file",
39                ));
40            }
41        }
42        Ok(Self {
43            path: path.to_path_buf(),
44            truncate: false,
45            cleanup: false,
46        })
47    }
48
49    pub fn comment() -> Self {
50        const COMMENT_FILE: &str = "RAD_COMMENT";
51
52        let path = env::temp_dir().join(COMMENT_FILE);
53
54        Self {
55            path,
56            truncate: true,
57            cleanup: true,
58        }
59    }
60
61    /// Set the file extension.
62    pub fn extension(mut self, ext: &str) -> Self {
63        let ext = ext.trim_start_matches('.');
64
65        self.path.set_extension(ext);
66        self
67    }
68
69    /// Truncate the file to length 0 when opening
70    pub fn truncate(mut self, truncate: bool) -> Self {
71        self.truncate = truncate;
72        self
73    }
74
75    /// Clean up the file after the [`Editor`] is dropped.
76    pub fn cleanup(mut self, cleanup: bool) -> Self {
77        self.cleanup = cleanup;
78        self
79    }
80
81    /// Initialize the file with the provided `content`, as long as the file
82    /// does not already contain anything.
83    #[allow(clippy::byte_char_slices)]
84    pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
85        let content = content.as_ref();
86        let mut file = fs::OpenOptions::new()
87            .write(true)
88            .create(true)
89            .truncate(self.truncate)
90            .open(&self.path)?;
91
92        if file.metadata()?.len() == 0 {
93            file.write_all(content)?;
94            if !content.ends_with(&[b'\n']) {
95                file.write_all(b"\n")?;
96            }
97            file.flush()?;
98        }
99        Ok(self)
100    }
101
102    /// Open the editor and return the edited text.
103    ///
104    /// If the text hasn't changed from the initial contents of the editor,
105    /// return `None`.
106    pub fn edit(&mut self) -> io::Result<Option<String>> {
107        let Some(cmd) = self::default_editor() else {
108            return Err(io::Error::new(
109                io::ErrorKind::NotFound,
110                "editor not configured: the `EDITOR` environment variable is not set",
111            ));
112        };
113
114        let lossy = cmd.to_string_lossy();
115
116        #[cfg(unix)]
117        let Some(parts) = shlex::split(&lossy) else {
118            return Err(io::Error::new(
119                io::ErrorKind::InvalidInput,
120                format!("invalid editor command {cmd:?}"),
121            ));
122        };
123
124        #[cfg(windows)]
125        let parts = winsplit::split(&lossy);
126
127        let Some((program, args)) = parts.split_first() else {
128            return Err(io::Error::new(
129                io::ErrorKind::InvalidInput,
130                format!("invalid editor command {cmd:?}"),
131            ));
132        };
133
134        let stdout: process::Stdio = {
135            #[cfg(unix)]
136            {
137                use std::os::fd::{AsRawFd as _, FromRawFd as _};
138
139                // We duplicate the stderr file descriptor to pass it to the child process; otherwise, if
140                // we simply pass the `RawFd` of our stderr, `Command` will close our stderr when the
141                // child exits.
142
143                let stderr = io::stderr().as_raw_fd();
144                unsafe { process::Stdio::from_raw_fd(libc::dup(stderr)) }
145            }
146            #[cfg(not(unix))]
147            {
148                // No duplication of the file handle for stderr on Windows.
149                // This might not always work, but is better than not being able to build for
150                // Windows.
151                io::stderr().into()
152            }
153        };
154
155        let stdin = if io::stdin().is_terminal() {
156            process::Stdio::inherit()
157        } else if cfg!(unix) {
158            // If standard input is not a terminal device, the editor won't work correctly.
159            // In that case, we use the terminal device, eg. `/dev/tty` as standard input.
160            let tty = fs::OpenOptions::new()
161                .read(true)
162                .write(true)
163                .open("/dev/tty")?;
164            process::Stdio::from(tty)
165        } else if cfg!(windows) {
166            let tty = fs::OpenOptions::new().read(true).open("CONIN$")?;
167            process::Stdio::from(tty)
168        } else {
169            return Err(io::Error::new(
170                io::ErrorKind::Unsupported,
171                format!("standard input is not a terminal, refusing to execute editor {cmd:?}"),
172            ));
173        };
174
175        process::Command::new(program)
176            .stdout(stdout)
177            .stderr(process::Stdio::inherit())
178            .stdin(stdin)
179            .args(args)
180            .arg(&self.path)
181            .spawn()
182            .map_err(|e| {
183                io::Error::new(
184                    e.kind(),
185                    format!("failed to spawn editor command {cmd:?}: {e}"),
186                )
187            })?
188            .wait()
189            .map_err(|e| {
190                io::Error::new(
191                    e.kind(),
192                    format!("editor command {cmd:?} didn't spawn: {e}"),
193                )
194            })?;
195
196        let text = fs::read_to_string(&self.path)?;
197        if text.trim().is_empty() {
198            return Ok(None);
199        }
200        Ok(Some(text))
201    }
202}
203
204/// Get the default editor command.
205fn default_editor() -> Option<OsString> {
206    // First check the standard environment variables.
207    if let Ok(visual) = env::var("VISUAL") {
208        if !visual.is_empty() {
209            return Some(visual.into());
210        }
211    }
212    if let Ok(editor) = env::var("EDITOR") {
213        if !editor.is_empty() {
214            return Some(editor.into());
215        }
216    }
217
218    // Check Git. The user might have configured their editor there.
219    // On Windows, custom editors configured via Git are not supported,
220    // because of the complexity surrounding how the editor command is
221    // parsed and executed. See also <https://stackoverflow.com/a/773973/1835188>.
222    #[cfg(all(feature = "git2", not(windows)))]
223    if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
224        return Some(path.into_os_string());
225    }
226
227    // On macOS, `nano` is installed by default and it's what most users are used to
228    // in the terminal.
229    #[cfg(target_os = "macos")]
230    if exists("nano") {
231        return Some("nano".into());
232    }
233
234    // On Windows, `edit` is available by default, see <https://learn.microsoft.com/windows/edit>.
235    #[cfg(windows)]
236    if exists("edit.exe") {
237        return Some("edit.exe".into());
238    }
239
240    // On Windows, `notepad` is commonly available for decades, see <https://apps.microsoft.com/detail/9msmlrh6lzf3>.
241    #[cfg(windows)]
242    if exists("notepad.exe") {
243        return Some("notepad.exe".into());
244    }
245
246    // If all else fails, we try `vi`. It's usually installed on most unix-based systems.
247    if exists("vi") {
248        return Some("vi".into());
249    }
250
251    None
252}
253
254/// Check whether a binary can be found in the most common paths on Unix-like systems.
255/// We don't bother checking the `$PATH` variable, as we're only looking for very standard tools
256/// and prefer not to make this too complex.
257#[cfg(unix)]
258fn exists(cmd: &str) -> bool {
259    // Some common paths where system-installed binaries are found.
260    const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
261
262    for dir in PATHS {
263        if Path::new(dir).join(cmd).exists() {
264            return true;
265        }
266    }
267    false
268}
269
270/// Check whether a binary can be found on `$PATH`.
271/// See:
272///  - <https://devblogs.microsoft.com/scripting/weekend-scripter-where-exethe-what-why-and-how/>
273///  - <https://learn.microsoft.com/windows-server/administration/windows-commands/where>
274#[cfg(windows)]
275fn exists(cmd: &str) -> bool {
276    std::process::Command::new("where.exe")
277        .arg("/q")
278        .arg("$PATH:".to_owned() + cmd)
279        .output()
280        .map(|output| output.status.success())
281        .unwrap_or_default()
282}