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}