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}