editor_command/
lib.rs

1//! Get an executable [Command] to open a particular file in the user's
2//! configured editor.
3//!
4//! ## Features
5//!
6//! - Load editor command from the `VISUAL` or `EDITOR` environment variables
7//! - Specify high-priority override and low-priority default commands to use
8//! - Pass one or more paths to be opened by the editor
9//! - Flexible builder pattern
10//!
11//! ## Examples
12//!
13//! The simplest usage looks like this:
14//!
15//! ```
16//! # // Hide this part because it doesn't provide any value to the user
17//! # let _guard = env_lock::lock_env([
18//! #     ("VISUAL", None::<&str>),
19//! #     ("EDITOR", None),
20//! # ]);
21//! use editor_command::EditorBuilder;
22//! use std::process::Command;
23//!
24//! std::env::set_var("VISUAL", "vim");
25//! let command: Command = EditorBuilder::edit_file("file.txt").unwrap();
26//! assert_eq!(command.get_program(), "vim");
27//! ```
28//!
29//! Here's an example of using the builder pattern to provide both an override
30//! and a fallback command to [EditorBuilder]:
31//!
32//! ```
33//! # // Hide this part because it doesn't provide any value to the user
34//! # let _guard = env_lock::lock_env([
35//! #     ("VISUAL", None::<&str>),
36//! #     ("EDITOR", None),
37//! # ]);
38//! use editor_command::EditorBuilder;
39//! use std::process::Command;
40//!
41//! // In your app, this could be an optional field from a config object
42//! let override_command = Some("code --wait");
43//! let command: Command = EditorBuilder::new()
44//!     // In this case, the override is always populated so it will always win.
45//!     // In reality it would be an optional user-provided field.
46//!     .source(override_command)
47//!     .environment()
48//!     // If both VISUAL and EDITOR are undefined, we'll fall back to this
49//!     .source(Some("vi"))
50//!     .build()
51//!     .unwrap();
52//! assert_eq!(format!("{command:?}"), "\"code\" \"--wait\"");
53//! ```
54//!
55//! This pattern is useful for apps that have a way to configure an app-specific
56//! editor. For example, [git has the `core.editor` config field](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration).
57//!
58//! ### Tokio
59//!
60//! [EditorBuilder] returns a `std` [Command], which will execute synchronously.
61//! If you want to run your editor subprocess asynchronously via
62//! [tokio](https://docs.rs/tokio/latest/tokio/), use the
63//! `From<std::process::Command>` impl on `tokio::process::Command`. For
64//! example:
65//!
66//! ```ignore
67//! let command: tokio::process::Command =
68//!     EditorBuilder::edit_file("file.yaml").unwrap().into();
69//! ```
70//!
71//! ## Syntax
72//!
73//! The syntax of the command is meant to resemble command syntax for common
74//! shells. The first word is the program name, and subsequent tokens (separated
75//! by spaces) are arguments to that program. Single and double quotes can be
76//! used to join multiple tokens together into a single argument.
77//!
78//! Command parsing is handled by the crate [shell-words]. Refer to those docs
79//! for exact details on the syntax.
80//!
81//! ## Lifetimes
82//!
83//! [EditorBuilder] accepts a lifetime parameter, which is bound to the string
84//! data it contains (both command strings and paths). This is to prevent
85//! unnecessary cloning when building commands/paths from `&str`s. If you need
86//! the instance of [EditorBuilder] to be `'static`, e.g. so it can be returned
87//! from a function, you can simply use `EditorBuilder<'static>`. Internally,
88//! all strings are stored as [Cow]s, so clones will be made as necessary.
89//!
90//! ```rust
91//! use editor_command::EditorBuilder;
92//!
93//! /// This is a contrived example of returning a command with owned data
94//! fn get_editor_builder<'a>(command: &'a str) -> EditorBuilder<'static> {
95//!     // The lifetime bounds enforce the .to_owned() call
96//!     EditorBuilder::new().source(Some(command.to_owned()))
97//! }
98//!
99//! let command = get_editor_builder("vim").build().unwrap();
100//! assert_eq!(command.get_program(), "vim");
101//! ```
102//!
103//! ## Resources
104//!
105//! For more information on the `VISUAL` and `EDITOR` environment variables,
106//! [check out this thread](https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference).
107
108use std::{
109    borrow::Cow,
110    env,
111    error::Error,
112    fmt::{self, Display},
113    path::Path,
114    process::Command,
115};
116
117/// A builder for a [Command] that will open the user's configured editor. For
118/// simple cases you probably can just use [EditorBuilder::edit_file]. See
119/// [crate-level documentation](crate) for more details and examples.
120#[derive(Clone, Debug, Default)]
121pub struct EditorBuilder<'a> {
122    /// Command to parse. This will be populated the first time we're given a
123    /// source with a value. After that, it remains unchanged.
124    command: Option<Cow<'a, str>>,
125    /// Path(s) to pass as the final argument(s) to the command
126    paths: Vec<Cow<'a, Path>>,
127}
128
129impl<'a> EditorBuilder<'a> {
130    /// Create a new editor command with no sources. You probably want to call
131    /// [environment](Self::environment) on the returned value.
132    pub fn new() -> Self {
133        Self::default()
134    }
135
136    /// Shorthand for opening a file with the command set in `VISUAL`/`EDITOR`.
137    ///
138    /// ```ignore
139    /// EditorBuilder::edit_file("file.yml")
140    /// ```
141    ///
142    /// is equivalent to:
143    ///
144    /// ```ignore
145    /// EditorBuilder::new().environment().path(path).build()
146    /// ```
147    pub fn edit_file(
148        // This is immediately being built, so we can accept AsRef<Path>
149        // instead of Into<Cow<'a, Path>> because we know we won't need an
150        // owned PathBuf. This allows us to accept &str, which is nice
151        path: impl AsRef<Path>,
152    ) -> Result<Command, EditorBuilderError> {
153        Self::new().environment().path(path.as_ref()).build()
154    }
155
156    /// Add a static string as a source for the command. This is useful for
157    /// static defaults, or external sources such as a configuration file.
158    /// This accepts an `Option` so you can easily build a chain of sources
159    /// that may or may not be defined.
160    pub fn source(mut self, source: Option<impl Into<Cow<'a, str>>>) -> Self {
161        self.command = self.command.or(source.map(Into::into));
162        self
163    }
164
165    /// Add the `VISUAL` and `EDITOR` environment variables, in that order. The
166    /// variables will be evaluated **immediately**, *not* during
167    /// [build](Self::build).
168    pub fn environment(mut self) -> Self {
169        // Populate command if it isn't already
170        self.command = self
171            .command
172            .or_else(|| env::var("VISUAL").ok().map(Cow::from))
173            .or_else(|| env::var("EDITOR").ok().map(Cow::from));
174        self
175    }
176
177    /// Define the path to be passed as the final argument.
178    ///
179    /// ## Multiple Calls
180    ///
181    /// Subsequent calls to this on the same instance will append to the list
182    /// of paths. The paths will all be included in the final command, in the
183    /// order this method was called.
184    pub fn path(mut self, path: impl Into<Cow<'a, Path>>) -> Self {
185        self.paths.push(path.into());
186        self
187    }
188
189    /// Search all configured sources (in their order of definition), and parse
190    /// the first one that's populated as a shell command. Then use that to
191    /// build an executable [Command].
192    pub fn build(self) -> Result<Command, EditorBuilderError> {
193        // Find the first source that has a value. We *don't* validate that the
194        // command is non-empty or parses. If something has a value, it's better
195        // to use it and give the user an error if it's invalid, than to
196        // silently skip past it.
197        let command_str = self.command.ok_or(EditorBuilderError::NoCommand)?;
198
199        // Parse it as a shell command
200        let mut parsed = shell_words::split(&command_str)
201            .map_err(EditorBuilderError::ParseError)?;
202
203        // First token is the program name, rest are arguments
204        let mut tokens = parsed.drain(..);
205        let program = tokens.next().ok_or(EditorBuilderError::EmptyCommand)?;
206        let args = tokens;
207
208        let mut command = Command::new(program);
209        command
210            .args(args)
211            .args(self.paths.iter().map(|path| path.as_os_str()));
212        Ok(command)
213    }
214}
215
216/// Any error that can occur while loading the editor command.
217#[derive(Debug)]
218pub enum EditorBuilderError {
219    /// Couldn't find an editor command anywhere
220    NoCommand,
221
222    /// The editor command was found, but it's just an empty/whitespace string
223    EmptyCommand,
224
225    /// Editor command couldn't be parsed in a shell-like format
226    ParseError(shell_words::ParseError),
227}
228
229impl Display for EditorBuilderError {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        match self {
232            EditorBuilderError::NoCommand => write!(
233                f,
234                "Edit command not defined in any of the listed sources"
235            ),
236            EditorBuilderError::EmptyCommand => {
237                write!(f, "Editor command is empty")
238            }
239            EditorBuilderError::ParseError(source) => {
240                write!(f, "Invalid editor command: {source}")
241            }
242        }
243    }
244}
245
246impl Error for EditorBuilderError {
247    fn source(&self) -> Option<&(dyn Error + 'static)> {
248        match self {
249            EditorBuilderError::NoCommand
250            | EditorBuilderError::EmptyCommand => None,
251            EditorBuilderError::ParseError(source) => Some(source),
252        }
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use std::{ffi::OsStr, path::PathBuf};
260
261    /// Test loading from a static source that overrides the environment
262    #[test]
263    fn source_priority() {
264        let builder = {
265            let _guard = env_lock::lock_env([
266                ("VISUAL", Some("visual")),
267                ("EDITOR", Some("editor")),
268            ]);
269            EditorBuilder::new()
270                .source(None::<&str>)
271                .source(Some("priority"))
272                .environment()
273                .source(Some("default"))
274        };
275        assert_cmd(builder, "priority", &[]);
276    }
277
278    /// Test loading from the `VISUAL` env var
279    #[test]
280    fn source_visual() {
281        let builder = {
282            let _guard = env_lock::lock_env([
283                ("VISUAL", Some("visual")),
284                ("EDITOR", Some("editor")),
285            ]);
286            EditorBuilder::new().environment().source(Some("default"))
287        };
288        assert_cmd(builder, "visual", &[]);
289    }
290
291    /// Test loading from the `EDITOR` env var
292    #[test]
293    fn source_editor() {
294        let builder = {
295            let _guard = env_lock::lock_env([
296                ("VISUAL", None),
297                ("EDITOR", Some("editor")),
298            ]);
299            EditorBuilder::new().environment().source(Some("default"))
300        };
301        assert_cmd(builder, "editor", &[]);
302    }
303
304    /// Test loading from a fallback value, with lower precedence than the env
305    #[test]
306    fn source_default() {
307        let builder = {
308            let _guard = env_lock::lock_env([
309                ("VISUAL", None::<&str>),
310                ("EDITOR", None),
311            ]);
312            EditorBuilder::new().environment().source(Some("default"))
313        };
314        assert_cmd(builder, "default", &[]);
315    }
316
317    /// Test included paths as extra arguments
318    #[test]
319    fn paths() {
320        let builder = EditorBuilder::new()
321            .source(Some("ed"))
322            // All of these types should be accepted, for ergonomics
323            .path(Path::new("path1"))
324            .path(PathBuf::from("path2".to_owned()));
325        assert_cmd(builder, "ed", &["path1", "path2"]);
326    }
327
328    /// Test simple command parsing logic. We'll defer edge cases to shell-words
329    #[test]
330    fn parsing() {
331        let builder = EditorBuilder::new()
332            .source(Some("ned '--single \" quotes' \"--double ' quotes\""));
333        assert_cmd(
334            builder,
335            "ned",
336            &["--single \" quotes", "--double ' quotes"],
337        );
338    }
339
340    /// Test when all options are undefined
341    #[test]
342    fn error_no_command() {
343        let _guard = env_lock::lock_env([
344            ("VISUAL", None::<&str>),
345            ("EDITOR", None::<&str>),
346        ]);
347        assert_err(
348            EditorBuilder::new().environment().source(None::<&str>),
349            "Edit command not defined in any of the listed sources",
350        );
351    }
352
353    /// Test when the command exists but is the empty string
354    #[test]
355    fn error_empty_command() {
356        assert_err(
357            EditorBuilder::new().source(Some("")),
358            "Editor command is empty",
359        );
360    }
361
362    /// Test when a value can't be parsed as a command string
363    #[test]
364    fn error_invalid_command() {
365        assert_err(
366            EditorBuilder::new().source(Some("'unclosed quote")),
367            "Invalid editor command: missing closing quote",
368        );
369    }
370
371    /// Assert that the builder creates the expected command
372    fn assert_cmd(
373        builder: EditorBuilder,
374        expected_program: &str,
375        expected_args: &[&str],
376    ) {
377        let command = builder.build().unwrap();
378        assert_eq!(command.get_program(), expected_program);
379        assert_eq!(
380            command
381                .get_args()
382                .filter_map(OsStr::to_str)
383                .collect::<Vec<_>>(),
384            expected_args
385        );
386    }
387
388    /// Assert that the builder fails to build with the given error message
389    fn assert_err(builder: EditorBuilder, expected_error: &str) {
390        let error = builder.build().unwrap_err();
391        assert_eq!(error.to_string(), expected_error);
392    }
393}