edit/lib.rs
1//! `edit` lets you open and edit something in a text editor, regardless of platform.
2//! (Think `git commit`.)
3//!
4//! It works on Windows, Mac, and Linux, and [knows about] lots of different text editors to fall
5//! back upon in case standard environment variables such as `VISUAL` and `EDITOR` aren't set.
6//!
7//! ```rust,ignore
8//! let template = "Fill in the blank: Hello, _____!";
9//! let edited = edit::edit(template)?;
10//! println!("after editing: '{}'", edited);
11//! // after editing: 'Fill in the blank: Hello, world!'
12//! ```
13//!
14//! [knows about]: ../src/edit/lib.rs.html#31-61
15//!
16//! Features
17//! ========
18//!
19//! The `edit` crate has the following optional features:
20//!
21//! - `better-path` *(enabled by default)* — Use
22//! [`which`](https://docs.rs/which) to locate executable programs in `PATH`.
23//! If this is disabled, programs are still looked up in `PATH`, but a basic
24//! search is used that does not check for executability.
25//!
26//! - `quoted-env` — Use [`shell-words`](https://docs.rs/shell-words) to split
27//! apart the values of the `VISUAL` and `EDITOR` environment variables. If
28//! this is disabled, the envvars are split up on whitespace.
29
30use std::{
31 env,
32 ffi::OsStr,
33 fs,
34 io::{Error, ErrorKind, Result, Write},
35 path::{Path, PathBuf},
36 process::{Command, Stdio},
37};
38pub use tempfile::Builder;
39#[cfg(feature = "which")]
40use which::which;
41
42static ENV_VARS: &[&str] = &["VISUAL", "EDITOR"];
43
44// TODO: should we hardcode full paths as well in case $PATH is borked?
45#[cfg(not(any(target_os = "windows", target_os = "macos")))]
46#[rustfmt::skip]
47static HARDCODED_NAMES: &[&str] = &[
48 // CLI editors
49 "sensible-editor", "nano", "pico", "vim", "nvim", "vi", "emacs",
50 // GUI editors
51 "code", "atom", "subl", "gedit", "gvim",
52 // Generic "file openers"
53 "xdg-open", "gnome-open", "kde-open",
54];
55
56#[cfg(target_os = "macos")]
57#[rustfmt::skip]
58static HARDCODED_NAMES: &[&str] = &[
59 // CLI editors
60 "nano", "pico", "vim", "nvim", "vi", "emacs",
61 // open has a special flag to open in the default text editor
62 // (this really should come before the CLI editors, but in order
63 // not to break compatibility, we still prefer CLI over GUI)
64 "open -Wt",
65 // GUI editors
66 "code -w", "atom -w", "subl -w", "gvim", "mate",
67 // Generic "file openers"
68 "open -a TextEdit",
69 "open -a TextMate",
70 // TODO: "open -f" reads input from standard input and opens with
71 // TextEdit. if this flag were used we could skip the tempfile
72 "open",
73];
74
75#[cfg(target_os = "windows")]
76#[rustfmt::skip]
77static HARDCODED_NAMES: &[&str] = &[
78 // GUI editors
79 "code.cmd -n -w", "atom.exe -w", "subl.exe -w",
80 // notepad++ does not block for input
81 // Installed by default
82 "notepad.exe",
83 // Generic "file openers"
84 "cmd.exe /C start",
85];
86
87#[cfg(feature = "better-path")]
88fn get_full_editor_path<T: AsRef<OsStr>>(binary_name: T) -> which::Result<PathBuf> {
89 which(binary_name)
90}
91
92#[cfg(not(feature = "better-path"))]
93fn get_full_editor_path<T: AsRef<OsStr> + AsRef<Path>>(binary_name: T) -> Result<PathBuf> {
94 if let Some(paths) = env::var_os("PATH") {
95 for dir in env::split_paths(&paths) {
96 if dir.join(&binary_name).is_file() {
97 return Ok(dir.join(&binary_name));
98 }
99 }
100 }
101
102 Err(Error::from(ErrorKind::NotFound))
103}
104
105#[cfg(not(feature = "quoted-env"))]
106fn string_to_cmd(s: String) -> (PathBuf, Vec<String>) {
107 let mut args = s.split_ascii_whitespace();
108 (
109 args.next().unwrap().into(),
110 args.map(String::from).collect(),
111 )
112}
113
114#[cfg(feature = "quoted-env")]
115fn string_to_cmd(s: String) -> (PathBuf, Vec<String>) {
116 match shell_words::split(&s) {
117 Ok(mut v) if !v.is_empty() => (v.remove(0).into(), v),
118 _ => {
119 let mut args = s.split_ascii_whitespace();
120 (
121 args.next().unwrap().into(),
122 args.map(String::from).collect(),
123 )
124 }
125 }
126}
127
128fn get_full_editor_cmd(s: String) -> Result<(PathBuf, Vec<String>)> {
129 let (path, args) = string_to_cmd(s);
130 match get_full_editor_path(&path) {
131 Ok(result) => Ok((result, args)),
132 Err(_) if path.exists() => Ok((path, args)),
133 Err(_) => Err(Error::from(ErrorKind::NotFound)),
134 }
135}
136
137fn get_editor_args() -> Result<(PathBuf, Vec<String>)> {
138 ENV_VARS
139 .iter()
140 .filter_map(env::var_os)
141 .filter(|v| !v.is_empty())
142 .filter_map(|v| v.into_string().ok())
143 .filter_map(|s| get_full_editor_cmd(s).ok())
144 .next()
145 .or_else(|| {
146 HARDCODED_NAMES
147 .iter()
148 .map(|s| s.to_string())
149 .filter_map(|s| get_full_editor_cmd(s).ok())
150 .next()
151 })
152 .ok_or_else(|| Error::from(ErrorKind::NotFound))
153}
154
155/// Find the system default editor, if there is one.
156///
157/// This function checks several sources to find an editor binary (in order of precedence):
158///
159/// - the `VISUAL` environment variable
160/// - the `EDITOR` environment variable
161/// - hardcoded lists of common CLI editors on MacOS/Unix
162/// - hardcoded lists of GUI editors on Windows/MacOS/Unix
163/// - platform-specific generic "file openers" (e.g. `xdg-open` on Linux and `open` on MacOS)
164///
165/// Also, it doesn't blindly return whatever is in an environment variable. If a specified editor
166/// can't be found or isn't marked as executable (the executable bit is checked when the default
167/// feature `better-path` is enabled), this function will fall back to the next one that is.
168///
169/// # Returns
170///
171/// If successful, returns the name of the system default editor.
172/// Note that in most cases the full path of the editor isn't returned; what is guaranteed is the
173/// return value being suitable as the program name for e.g. [`Command::new`].
174///
175/// On some platforms, a text editor is installed by default, so the chances of a failure are low
176/// save for `PATH` being unset or something weird like that. However, it is possible for one not
177/// to be located, and in that case `get_editor` will return [`ErrorKind::NotFound`].
178///
179/// # Example
180///
181/// ```rust,ignore
182/// use edit::get_editor;
183///
184/// // will print e.g. "default editor: nano"
185/// println!("default editor:", get_editor().expect("can't find an editor").to_str());
186/// ```
187///
188/// [`Command::new`]: https://doc.rust-lang.org/std/process/struct.Command.html#method.new
189/// [`ErrorKind::NotFound`]: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.NotFound
190pub fn get_editor() -> Result<PathBuf> {
191 get_editor_args().map(|(x, _)| x)
192}
193
194/// Open the contents of a string or buffer in the [default editor].
195///
196/// This function saves its input to a temporary file and then opens the default editor to it.
197/// It waits for the editor to return, re-reads the (possibly changed/edited) temporary file, and
198/// then deletes it.
199///
200/// # Arguments
201///
202/// `text` is written to the temporary file before invoking the editor. (The editor opens with
203/// the contents of `text` already in the file).
204///
205/// # Returns
206///
207/// If successful, returns the edited string.
208/// If the edited version of the file can't be decoded as UTF-8, returns [`ErrorKind::InvalidData`].
209/// If no text editor could be found, returns [`ErrorKind::NotFound`].
210/// Any errors related to spawning the editor process will also be passed through.
211///
212/// [default editor]: fn.get_editor.html
213/// [`ErrorKind::InvalidData`]: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.InvalidData
214/// [`ErrorKind::NotFound`]: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.NotFound
215pub fn edit<S: AsRef<[u8]>>(text: S) -> Result<String> {
216 let builder = Builder::new();
217 edit_with_builder(text, &builder)
218}
219
220/// Open the contents of a string or buffer in the [default editor] using a temporary file with a
221/// custom path or filename.
222///
223/// This function saves its input to a temporary file created using `builder`, then opens the
224/// default editor to it. It waits for the editor to return, re-reads the (possibly changed/edited)
225/// temporary file, and then deletes it.
226///
227/// Other than the custom [`Builder`], this function is identical to [`edit`].
228///
229/// # Arguments
230///
231/// `builder` is used to create a temporary file, potentially with a custom name, path, or prefix.
232///
233/// `text` is written to the temporary file before invoking the editor. (The editor opens with
234/// the contents of `text` already in the file).
235///
236/// # Returns
237///
238/// If successful, returns the edited string.
239/// If the temporary file can't be created with the provided builder, may return any error returned
240/// by [`OpenOptions::open`].
241/// If the edited version of the file can't be decoded as UTF-8, returns [`ErrorKind::InvalidData`].
242/// If no text editor could be found, returns [`ErrorKind::NotFound`].
243/// Any errors related to spawning the editor process will also be passed through.
244///
245/// [default editor]: fn.get_editor.html
246/// [`edit`]: fn.edit.html
247/// [`Builder`]: struct.Builder.html
248/// [`OpenOptions::open`]: https://doc.rust-lang.org/std/fs/struct.OpenOptions.html#errors
249/// [`ErrorKind::InvalidData`]: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.InvalidData
250/// [`ErrorKind::NotFound`]: https://doc.rust-lang.org/std/io/enum.ErrorKind.html#variant.NotFound
251pub fn edit_with_builder<S: AsRef<[u8]>>(text: S, builder: &Builder) -> Result<String> {
252 String::from_utf8(edit_bytes_with_builder(text, builder)?)
253 .map_err(|_| Error::from(ErrorKind::InvalidData))
254}
255
256/// Open the contents of a string or buffer in the [default editor] and return them as raw bytes.
257///
258/// See [`edit`], the version of this function that takes and returns [`String`].
259///
260/// # Arguments
261///
262/// `buf` is written to the temporary file before invoking the editor.
263///
264/// # Returns
265///
266/// If successful, returns the contents of the temporary file in raw (`Vec<u8>`) form.
267///
268/// [default editor]: fn.get_editor.html
269/// [`edit`]: fn.edit.html
270/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
271pub fn edit_bytes<B: AsRef<[u8]>>(buf: B) -> Result<Vec<u8>> {
272 let builder = Builder::new();
273 edit_bytes_with_builder(buf, &builder)
274}
275
276/// Open the contents of a string or buffer in the [default editor] using a temporary file with a
277/// custom path or filename and return them as raw bytes.
278///
279/// See [`edit_with_builder`], the version of this function that takes and returns [`String`].
280///
281/// Other than the custom [`Builder`], this function is identical to [`edit_bytes`].
282///
283/// # Arguments
284///
285/// `builder` is used to create a temporary file, potentially with a custom name, path, or prefix.
286///
287/// `buf` is written to the temporary file before invoking the editor.
288///
289/// # Returns
290///
291/// If successful, returns the contents of the temporary file in raw (`Vec<u8>`) form.
292///
293/// [default editor]: fn.get_editor.html
294/// [`edit_with_builder`]: fn.edit_with_builder.html
295/// [`String`]: https://doc.rust-lang.org/std/string/struct.String.html
296/// [`Builder`]: struct.Builder.html
297/// [`edit_bytes`]: fn.edit_bytes.html
298pub fn edit_bytes_with_builder<B: AsRef<[u8]>>(buf: B, builder: &Builder) -> Result<Vec<u8>> {
299 let mut file = builder.tempfile()?;
300 file.write_all(buf.as_ref())?;
301
302 let path = file.into_temp_path();
303 edit_file(&path)?;
304
305 let edited = fs::read(&path)?;
306
307 path.close()?;
308 Ok(edited)
309}
310
311/// Open an existing file (or create a new one, depending on the editor's behavior) in the
312/// [default editor] and wait for the editor to exit.
313///
314/// # Arguments
315///
316/// A [`Path`] to a file, new or existing, to open in the default editor.
317///
318/// # Returns
319///
320/// A Result is returned in case of errors finding or spawning the editor, but the contents of the
321/// file are not read and returned as in [`edit`] and [`edit_bytes`].
322///
323/// [default editor]: fn.get_editor.html
324/// [`Path`]: https://doc.rust-lang.org/std/path/struct.Path.html
325/// [`edit`]: fn.edit.html
326/// [`edit_bytes`]: fn.edit_bytes.html
327pub fn edit_file<P: AsRef<Path>>(file: P) -> Result<()> {
328 let (editor, args) = get_editor_args()?;
329 let status = Command::new(&editor)
330 .args(&args)
331 .arg(file.as_ref())
332 .stdin(Stdio::inherit())
333 .stdout(Stdio::inherit())
334 .stderr(Stdio::inherit())
335 .output()?
336 .status;
337
338 if status.success() {
339 Ok(())
340 } else {
341 let full_command = if args.is_empty() {
342 format!(
343 "{} {}",
344 editor.to_string_lossy(),
345 file.as_ref().to_string_lossy()
346 )
347 } else {
348 format!(
349 "{} {} {}",
350 editor.to_string_lossy(),
351 args.join(" "),
352 file.as_ref().to_string_lossy()
353 )
354 };
355
356 Err(Error::new(
357 ErrorKind::Other,
358 format!("editor '{}' exited with error: {}", full_command, status),
359 ))
360 }
361}
362
363/// Open an existing file (or create a new one, depending on the editor's behavior) in the
364/// [default editor] and *do not* wait for the editor to exit.
365///
366/// # Arguments
367///
368/// A [`Path`] to a file, new or existing, to open in the default editor.
369///
370/// # Returns
371///
372/// A Result is returned in case of errors finding or spawning the editor, but the contents of the
373/// file are not read and returned as in [`edit`] and [`edit_bytes`].
374///
375/// [default editor]: fn.get_editor.html
376/// [`Path`]: https://doc.rust-lang.org/std/path/struct.Path.html
377/// [`edit`]: fn.edit.html
378/// [`edit_bytes`]: fn.edit_bytes.html
379pub fn edit_file_without_waiting<P: AsRef<Path>>(file: P) -> Result<()> {
380 let (editor, args) = get_editor_args()?;
381 Command::new(&editor)
382 .args(&args)
383 .arg(file.as_ref())
384 .stdin(Stdio::null())
385 .stdout(Stdio::null())
386 .stderr(Stdio::null())
387 .spawn()?;
388 Ok(())
389}