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//! - Open files to a particular line/column
9//! - Flexible builder pattern
10//!
11//! ## Examples
12//!
13//! `editor-command` uses a two-stage abstraction:
14//!
15//! - Build an [Editor] (optionally using an [EditorBuilder]), which represents
16//!   a user's desired editor
17//! - Use [Editor::open] to build a [Command] that will open a particular file
18//!
19//! ### Simplest Usage
20//!
21//! ```
22//! # let _guard = env_lock::lock_env([
23//! #     ("VISUAL", None::<&str>),
24//! #     ("EDITOR", None),
25//! # ]);
26//! use editor_command::Editor;
27//! use std::process::Command;
28//!
29//! std::env::set_var("VISUAL", "vim");
30//! // Building an editor is fallible because the user's configured command may
31//! // be invalid (e.g. it could have unclosed quotes)
32//! let editor = Editor::new().unwrap();
33//! // Once we have an editor, building a Command is infallible
34//! let command: Command = editor.open("file.txt");
35//!
36//! assert_eq!(command.get_program(), "vim");
37//! assert_eq!(command.get_args().collect::<Vec<_>>(), &["file.txt"]);
38//!
39//! // You can spawn the editor with:
40//! // command.status().unwrap();
41//! ```
42//!
43//! ### Open to Line/Column
44//!
45//! You can open a file to particular line/column using [Editor::open_at]:
46//!
47//! ```
48//! # let _guard = env_lock::lock_env([
49//! #     ("VISUAL", None::<&str>),
50//! #     ("EDITOR", None),
51//! # ]);
52//! use editor_command::Editor;
53//! use std::process::Command;
54//!
55//! std::env::set_var("VISUAL", "vim");
56//! let editor = Editor::new().unwrap();
57//! let command: Command = editor.open_at("file.txt", 10, 5);
58//!
59//! assert_eq!(command.get_program(), "vim");
60//! assert_eq!(
61//!     command.get_args().collect::<Vec<_>>(),
62//!     &["file.txt", "+call cursor(10, 5)"],
63//! );
64//! ```
65//!
66//! See [Editor::open_at] for info on how it supports line/column for various
67//! editors, and how to support it for arbitrary user-provided commands.
68//!
69//! ### Overrides and Fallbacks
70//!
71//! Here's an example of using [EditorBuilder] to provide both an override
72//! and a fallback command:
73//!
74//! ```
75//! # let _guard = env_lock::lock_env([
76//! #     ("VISUAL", None::<&str>),
77//! #     ("EDITOR", None),
78//! # ]);
79//! use editor_command::EditorBuilder;
80//! use std::process::Command;
81//!
82//! std::env::set_var("VISUAL", "vim"); // This gets overridden
83//! let editor = EditorBuilder::new()
84//!     // In this case, the override is always populated so it will always win.
85//!     // In reality it would be an optional user-provided field.
86//!     .string(Some("code --wait"))
87//!     .environment()
88//!     // If both VISUAL and EDITOR are undefined, we'll fall back to this
89//!     .string(Some("vi"))
90//!     .build()
91//!     .unwrap();
92//! let command = editor.open("file.txt");
93//!
94//! assert_eq!(command.get_program(), "code");
95//! assert_eq!(command.get_args().collect::<Vec<_>>(), &["--wait", "file.txt"]);
96//! ```
97//!
98//! This pattern is useful for apps that have a way to configure an app-specific
99//! editor. For example, [git has the `core.editor` config field](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration).
100//!
101//! ### Tokio
102//!
103//! [Editor] returns a `std` [Command], which will execute synchronously.
104//! If you want to run your editor subprocess asynchronously via
105//! [tokio](https://docs.rs/tokio/latest/tokio/), use the
106//! `From<std::process::Command>` impl on `tokio::process::Command`. For
107//! example:
108//!
109//! ```ignore
110//! let editor = Editor::new().unwrap();
111//! let command: tokio::process::Command = editor.open("file.yaml").into();
112//! ```
113//!
114//! ## Syntax
115//!
116//! The syntax of the command is meant to resemble command syntax for common
117//! shells. The first word is the program name, and subsequent tokens (separated
118//! by spaces) are arguments to that program. Single and double quotes can be
119//! used to join multiple tokens together into a single argument.
120//!
121//! Command parsing is handled by the crate [shell-words](shell_words). Refer to
122//! those docs for exact details on the syntax.
123//!
124//! ## Resources
125//!
126//! For more information on the `VISUAL` and `EDITOR` environment variables,
127//! [check out this thread](https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference).
128
129use std::{
130    borrow::Cow,
131    env,
132    error::Error,
133    fmt::{self, Display},
134    path::Path,
135    process::Command,
136    str::FromStr,
137};
138
139/// An editor is a builder for [Command]s. An `Editor` instance represent's a
140/// user's desired editor, and can be used repeatedly to open files.
141#[derive(Clone, Debug)]
142pub struct Editor {
143    /// Binary to invoke
144    program: String,
145    known: Option<KnownEditor>,
146    /// Arguments to pass to the binary
147    arguments: Vec<String>,
148}
149
150impl Editor {
151    /// Create an editor from the user's `$VISUAL` or `$EDITOR` environment
152    /// variables. This is the easiest way to create an editor, but provides no
153    /// flexibility. See the [crate-level
154    /// documentation](crate#overrides-and-fallbacks) for an example of how
155    /// to use [EditorBuilder] to customize overrides and fallbacks.
156    ///
157    /// ```no_run
158    /// # use editor_command::{Editor, EditorBuilder};
159    /// Editor::new().unwrap();
160    /// // is equivalent to
161    /// EditorBuilder::new().environment().build().unwrap();
162    /// ```
163    ///
164    /// ### Errors
165    ///
166    /// Returns an error if:
167    /// - Neither `$VISUAL` nor `$EDITOR` is defined
168    /// - The command fails to parse (e.g. dangling quote)
169    pub fn new() -> Result<Self, EditorBuilderError> {
170        EditorBuilder::new().environment().build()
171    }
172
173    /// Build a command that will open a file
174    pub fn open(&self, path: impl AsRef<Path>) -> Command {
175        let mut command = Command::new(&self.program);
176        command.args(&self.arguments).arg(path.as_ref());
177        command
178    }
179
180    /// Build a command that will open a file to a particular line and column.
181    ///
182    /// Most editors accept the format `path:line:column`, so that's used by
183    /// default. This method supports some specific editors that don't follow
184    /// that convention. It will automatically detect these editors based on the
185    /// invoked command and pass the line/column accordingly:
186    ///
187    /// - `emacs`
188    /// - `vi`/`vim`/`nvim`
189    /// - `nano` (column not supported)
190    ///
191    /// If you want support for another editor that's not listed here, please
192    /// [open an issue on GitHub](https://github.com/LucasPickering/editor-command/issues/new/choose).
193    pub fn open_at(
194        &self,
195        path: impl AsRef<Path>,
196        line: u32,
197        column: u32,
198    ) -> Command {
199        let path = path.as_ref();
200        let mut command = Command::new(&self.program);
201        command.args(&self.arguments);
202
203        if let Some(known) = self.known {
204            // This editor requires special logic to open to a line/col
205            known.open_at(&mut command, path, line, column);
206        } else {
207            // This is a common format, so hope the editor supports it
208            command
209                .arg(format!("{path}:{line}:{column}", path = path.display()));
210        }
211
212        command
213    }
214}
215
216/// A builder for customizing an [Editor]. In simple cases you can just use
217/// [Editor::new] and don't have to interact with this struct. See [crate-level
218/// documentation](crate#overrides-and-fallbacks) for more details and examples.
219///
220/// ## Example
221///
222/// The builder works by calling one or more "source" methods. Each source may
223/// (or may not) provide an editor command. The first source that provides a
224/// command will be used, and subsequent sources will be ignored. For example,
225/// here's a builder that uses 3 sources:
226///
227/// - User's configured editor
228/// - Environment variables
229/// - Static fallback
230///
231/// ```
232/// # let _guard = env_lock::lock_env([
233/// #     ("VISUAL", None::<&str>),
234/// #     ("EDITOR", None),
235/// # ]);
236/// use editor_command::EditorBuilder;
237/// use std::process::Command;
238///
239/// std::env::set_var("VISUAL", "vim"); // This gets overridden
240/// let editor = EditorBuilder::new()
241///     .string(configured_editor())
242///     .environment()
243///     // If both VISUAL and EDITOR are undefined, we'll fall back to this
244///     .string(Some("vi"))
245///     .build()
246///     .unwrap();
247/// let command = editor.open("file.txt");
248///
249/// assert_eq!(command.get_program(), "code");
250/// assert_eq!(command.get_args().collect::<Vec<_>>(), &["--wait", "file.txt"]);
251///
252/// fn configured_editor() -> Option<String> {
253///     // In reality this would load from a config file or similar
254///     Some("code --wait".into())
255/// }
256/// ```
257///
258/// ## Lifetimes
259///
260/// [EditorBuilder] accepts a lifetime parameter, which is bound to the string
261/// data it contains (both command strings and paths). This is to prevent
262/// unnecessary cloning when building editors from `&str`s. If you need
263/// the instance of [EditorBuilder] to be `'static`, e.g. so it can be returned
264/// from a function, you can simply use `EditorBuilder<'static>`. Internally,
265/// all strings are stored as [Cow]s, so clones will be made as necessary. Once
266/// the builder is converted into an [Editor], all strings will be cloned.
267///
268/// ```rust
269/// use editor_command::EditorBuilder;
270///
271/// /// This is a contrived example of returning a command with owned data
272/// fn get_editor_builder<'a>(command: &'a str) -> EditorBuilder<'static> {
273///     // The lifetime bounds enforce the .to_owned() call
274///     EditorBuilder::new().string(Some(command.to_owned()))
275/// }
276///
277/// let editor = get_editor_builder("vim").build().unwrap();
278/// assert_eq!(editor.open("file").get_program(), "vim");
279/// ```
280#[derive(Clone, Debug, Default)]
281pub struct EditorBuilder<'a> {
282    /// Command to parse. This will be populated the first time we're given a
283    /// source with a value. After that, it remains unchanged.
284    command: Option<Cow<'a, str>>,
285}
286
287impl<'a> EditorBuilder<'a> {
288    /// Create a new editor command with no sources. You probably want to call
289    /// [environment](Self::environment) on the returned value.
290    pub fn new() -> Self {
291        Self::default()
292    }
293
294    /// Load the editor command from a string. This is useful for static
295    /// defaults or external sources such as a configuration file. This accepts
296    /// an `Option` so you can easily build a chain of sources that may or may
297    /// not be defined.
298    pub fn string(mut self, source: Option<impl Into<Cow<'a, str>>>) -> Self {
299        self.command = self.command.or(source.map(Into::into));
300        self
301    }
302
303    /// Load the editor command from the `VISUAL` and `EDITOR` environment
304    /// variables, in that order. The variables will be evaluated immediately,
305    /// *not* during [build](Self::build).
306    pub fn environment(mut self) -> Self {
307        // Populate command if it isn't already
308        self.command = self
309            .command
310            .or_else(|| env::var("VISUAL").ok().map(Cow::from))
311            .or_else(|| env::var("EDITOR").ok().map(Cow::from));
312        self
313    }
314
315    /// Search all configured sources (in their order of definition), and parse
316    /// the first one that's populated as a shell command. Then use that to
317    /// build an executable [Command].
318    pub fn build(self) -> Result<Editor, EditorBuilderError> {
319        // Find the first source that has a value. We *don't* validate that the
320        // command is non-empty or parses. If something has a value, it's better
321        // to use it and give the user an error if it's invalid, than to
322        // silently skip past it.
323        let command_str = self.command.ok_or(EditorBuilderError::NoCommand)?;
324
325        // Parse it as a shell command
326        let mut parsed = shell_words::split(&command_str)
327            .map_err(EditorBuilderError::ParseError)?;
328
329        // First token is the program name, rest are arguments
330        let mut tokens = parsed.drain(..);
331        let program = tokens.next().ok_or(EditorBuilderError::EmptyCommand)?;
332        let arguments = tokens.collect();
333        // Check the program name to see if we recognize this editor
334        let known = program.parse().ok();
335
336        Ok(Editor {
337            program,
338            known,
339            arguments,
340        })
341    }
342}
343
344/// Any error that can occur while loading the editor command.
345#[derive(Debug)]
346pub enum EditorBuilderError {
347    /// Couldn't find an editor command anywhere
348    NoCommand,
349
350    /// The editor command was found, but it's just an empty/whitespace string
351    EmptyCommand,
352
353    /// Editor command couldn't be parsed in a shell-like format
354    ParseError(shell_words::ParseError),
355}
356
357impl Display for EditorBuilderError {
358    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359        match self {
360            EditorBuilderError::NoCommand => write!(
361                f,
362                "Edit command not defined in any of the listed sources"
363            ),
364            EditorBuilderError::EmptyCommand => {
365                write!(f, "Editor command is empty")
366            }
367            EditorBuilderError::ParseError(source) => {
368                write!(f, "Invalid editor command: {source}")
369            }
370        }
371    }
372}
373
374impl Error for EditorBuilderError {
375    fn source(&self) -> Option<&(dyn Error + 'static)> {
376        match self {
377            EditorBuilderError::NoCommand
378            | EditorBuilderError::EmptyCommand => None,
379            EditorBuilderError::ParseError(source) => Some(source),
380        }
381    }
382}
383
384/// A known editor that requires special logic to open to a line/column. Most
385/// editors support the common `path:line:column` format and don't need to be
386/// specified here.
387#[derive(Copy, Clone, Debug)]
388enum KnownEditor {
389    Emacs,
390    Nano,
391    /// Also includes Vim and Neovim
392    Vi,
393    // If you add a variant here, make sure to update the docs on open_at
394}
395
396impl KnownEditor {
397    // It'd be nice to use strum for this but I don't want it in the dep tree
398    /// All variants of the enum
399    const ALL: &'static [Self] = &[Self::Emacs, Self::Nano, Self::Vi];
400
401    /// Add arguments to the given command to open a file at a particular
402    /// line+column
403    fn open_at(
404        &self,
405        command: &mut Command,
406        path: &Path,
407        line: u32,
408        column: u32,
409    ) {
410        match self {
411            KnownEditor::Emacs => {
412                // Offset has to go first
413                command.arg(format!("+{line}:{column}")).arg(path);
414            }
415            // From my 6 seconds of research, nano doesn't support column
416            KnownEditor::Nano => {
417                // Offset has to go first
418                command.arg(format!("+{line}")).arg(path);
419            }
420            KnownEditor::Vi => {
421                command
422                    .arg(path)
423                    .arg(format!("+call cursor({line}, {column})"));
424            }
425        }
426    }
427
428    fn programs(&self) -> &'static [&'static str] {
429        match self {
430            Self::Emacs => &["emacs"],
431            Self::Nano => &["nano"],
432            Self::Vi => &["vi", "vim", "nvim"],
433        }
434    }
435}
436
437impl FromStr for KnownEditor {
438    type Err = ();
439
440    fn from_str(s: &str) -> Result<Self, Self::Err> {
441        Self::ALL
442            .iter()
443            // Intentionally do a case-sensitive match, because binary names
444            // are case-sensitive on some systems
445            .find(|known| known.programs().contains(&s))
446            .copied()
447            .ok_or(())
448    }
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use rstest::rstest;
455    use std::path::PathBuf;
456
457    /// Test loading from a static source that overrides the environment
458    #[test]
459    fn source_priority() {
460        let editor = {
461            let _guard = env_lock::lock_env([
462                ("VISUAL", Some("visual")),
463                ("EDITOR", Some("editor")),
464            ]);
465            EditorBuilder::new()
466                .string(None::<&str>)
467                .string(Some("priority"))
468                .environment()
469                .string(Some("default"))
470                .build()
471                .unwrap()
472        };
473        assert_cmd(editor.open("file"), "priority", &["file"]);
474    }
475
476    /// Test loading from the `VISUAL` env var
477    #[test]
478    fn source_visual() {
479        let editor = {
480            let _guard = env_lock::lock_env([
481                ("VISUAL", Some("visual")),
482                ("EDITOR", Some("editor")),
483            ]);
484            EditorBuilder::new()
485                .environment()
486                .string(Some("default"))
487                .build()
488                .unwrap()
489        };
490        assert_cmd(editor.open("file"), "visual", &["file"]);
491    }
492
493    /// Test loading from the `EDITOR` env var
494    #[test]
495    fn source_editor() {
496        let editor = {
497            let _guard = env_lock::lock_env([
498                ("VISUAL", None),
499                ("EDITOR", Some("editor")),
500            ]);
501            EditorBuilder::new()
502                .environment()
503                .string(Some("default"))
504                .build()
505                .unwrap()
506        };
507        assert_cmd(editor.open("file"), "editor", &["file"]);
508    }
509
510    /// Test loading from a fallback value, with lower precedence than the env
511    #[test]
512    fn source_default() {
513        let editor = {
514            let _guard = env_lock::lock_env([
515                ("VISUAL", None::<&str>),
516                ("EDITOR", None),
517            ]);
518            EditorBuilder::new()
519                .environment()
520                .string(Some("default"))
521                .build()
522                .unwrap()
523        };
524        assert_cmd(editor.open("file"), "default", &["file"]);
525    }
526
527    /// Test `open()` for known and unknown editors
528    #[rstest]
529    #[case::emacs("emacs", "emacs", &["file"])]
530    #[case::nano("nano", "nano", &["file"])]
531    #[case::vi("vi", "vi", &["file"])]
532    #[case::vi_with_args("vi -b", "vi", &["-b", "file"])]
533    #[case::vim("vim", "vim", &["file"])]
534    #[case::neovim("nvim", "nvim", &["file"])]
535    #[case::unknown("unknown --arg", "unknown", &["--arg", "file"])]
536    fn open(
537        #[case] command: &str,
538        #[case] expected_program: &str,
539        #[case] expected_args: &[&str],
540    ) {
541        let editor =
542            EditorBuilder::new().string(Some(command)).build().unwrap();
543        assert_cmd(editor.open("file"), expected_program, expected_args);
544    }
545
546    /// Test `open_at()` for known and unknown editors
547    #[rstest]
548    #[case::emacs("emacs", "emacs", &["+2:3", "file"])]
549    // Nano doesn't support column
550    #[case::nano("nano", "nano", &["+2", "file"])]
551    #[case::vi("vi", "vi", &["file", "+call cursor(2, 3)"])]
552    #[case::vi_with_args("vi -b", "vi", &["-b", "file", "+call cursor(2, 3)"])]
553    #[case::vim("vim", "vim", &["file", "+call cursor(2, 3)"])]
554    #[case::neovim("nvim", "nvim", &["file", "+call cursor(2, 3)"])]
555    // Default to path:line:column
556    #[case::unknown("unknown --arg", "unknown", &["--arg", "file:2:3"])]
557    fn open_at(
558        #[case] command: &str,
559        #[case] expected_program: &str,
560        #[case] expected_args: &[&str],
561    ) {
562        let editor =
563            EditorBuilder::new().string(Some(command)).build().unwrap();
564        assert_cmd(
565            editor.open_at("file", 2, 3),
566            expected_program,
567            expected_args,
568        );
569    }
570
571    /// Test included paths as extra arguments
572    #[test]
573    fn paths() {
574        let editor = EditorBuilder::new().string(Some("ed")).build().unwrap();
575        // All of these types should be accepted, for ergonomics
576        assert_cmd(editor.open("str"), "ed", &["str"]);
577        assert_cmd(editor.open(Path::new("path")), "ed", &["path"]);
578        assert_cmd(editor.open(PathBuf::from("pathbuf")), "ed", &["pathbuf"]);
579    }
580
581    /// Test simple command parsing logic. We'll defer edge cases to shell-words
582    #[test]
583    fn parsing() {
584        let editor = EditorBuilder::new()
585            .string(Some("ned '--single \" quotes' \"--double ' quotes\""))
586            .build()
587            .unwrap();
588        assert_cmd(
589            editor.open("file"),
590            "ned",
591            &["--single \" quotes", "--double ' quotes", "file"],
592        );
593    }
594
595    /// Test when all options are undefined
596    #[test]
597    fn error_no_command() {
598        let _guard = env_lock::lock_env([
599            ("VISUAL", None::<&str>),
600            ("EDITOR", None::<&str>),
601        ]);
602        assert_err(
603            EditorBuilder::new().environment().string(None::<&str>),
604            "Edit command not defined in any of the listed sources",
605        );
606    }
607
608    /// Test when the command exists but is the empty string
609    #[test]
610    fn error_empty_command() {
611        assert_err(
612            EditorBuilder::new().string(Some("")),
613            "Editor command is empty",
614        );
615    }
616
617    /// Test when a value can't be parsed as a command string
618    #[test]
619    fn error_invalid_command() {
620        assert_err(
621            EditorBuilder::new().string(Some("'unclosed quote")),
622            "Invalid editor command: missing closing quote",
623        );
624    }
625
626    /// Assert that the editor creates the expected command
627    #[track_caller]
628    fn assert_cmd(
629        command: Command,
630        expected_program: &str,
631        expected_args: &[&str],
632    ) {
633        assert_eq!(command.get_program(), expected_program);
634        assert_eq!(command.get_args().collect::<Vec<_>>(), expected_args);
635    }
636
637    /// Assert that the builder fails to build with the given error message
638    #[track_caller]
639    fn assert_err(builder: EditorBuilder, expected_error: &str) {
640        let error = builder.build().unwrap_err();
641        assert_eq!(error.to_string(), expected_error);
642    }
643}