editor-command-2.0.0/.cargo_vcs_info.json0000644000000001360000000000100137350ustar { "git": { "sha1": "b9ea3cdde6a37cf8a7d7b5433e8896309d56a276" }, "path_in_vcs": "" }editor-command-2.0.0/.clippy.toml000064400000000000000000000000001046102023000147660ustar 00000000000000editor-command-2.0.0/.github/ISSUE_TEMPLATE/bug_report.md000064400000000000000000000012341046102023000207420ustar 00000000000000--- name: Bug report about: Create a report to help us improve title: "" labels: bug assignees: "" --- **Did you [search](https://github.com/LucasPickering/slumber/issues) for existing issues already?** **Describe the bug** _A clear and concise description of what the bug is_ **To Reproduce** _Steps to reproduce the behavior_ 1. **Expected behavior** _A clear and concise description of what you expected to happen_ **Screenshots** _If applicable, add screenshots to help explain your problem_ **Version (please complete the following information):** - OS: - Terminal: - Slumber Version: **Additional context** Add any other context about the problem here. editor-command-2.0.0/.github/ISSUE_TEMPLATE/config.yml000064400000000000000000000000331046102023000202340ustar 00000000000000blank_issues_enabled: falseeditor-command-2.0.0/.github/ISSUE_TEMPLATE/feature_request.md000064400000000000000000000012741046102023000220010ustar 00000000000000--- name: Feature request about: Suggest an idea for this project title: "" labels: "" assignees: "" --- **Did you [search](https://github.com/LucasPickering/slumber/issues) for existing issues already?** **Is your feature request related to a problem? Please describe.** _A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]_ **Describe the solution you'd like** _A clear and concise description of what you want to happen_ **Describe alternatives you've considered** _A clear and concise description of any alternative solutions or features you've considered_ **Additional context** _Add any other context or screenshots about the feature request here_ editor-command-2.0.0/.github/workflows/test.yml000064400000000000000000000055261046102023000176340ustar 00000000000000name: Test on: push: branches: - master pull_request: # This uses the toolchain defined in rust-toolchain jobs: fmt: name: "Rustfmt" runs-on: ubuntu-latest env: # Rustfmt requires a nightly toolchain because we use unstable rules. The # chosen version is fairly arbitrary TOOLCHAIN: nightly-2024-04-20 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ env.TOOLCHAIN }} components: rustfmt - name: Cache Rust files uses: swatinem/rust-cache@v2 - name: Rustfmt run: cargo +${{ env.TOOLCHAIN }} fmt -- --check lint: name: Check/Lint - ${{ matrix.platform.name }} strategy: fail-fast: false matrix: # Run linting on every platform to make sure we didn't break any builds. # This is a subset of the Rust targets we support, just one per OS. platform: - name: Linux os: ubuntu-latest target: x86_64-unknown-linux-gnu - name: Windows os: windows-latest target: x86_64-pc-windows-msvc - name: macOS os: macOS-latest target: aarch64-apple-darwin runs-on: ${{ matrix.platform.os }} steps: - uses: actions/checkout@v4 - name: Cache Rust files uses: swatinem/rust-cache@v2 with: key: ${{ matrix.platform.target }} - name: Install toolchain run: rustup target add ${{ matrix.platform.target }} - name: Clippy run: cargo clippy --target ${{ matrix.platform.target }} --all-targets --all-features -- -D clippy::all doc: name: Check Docs runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Cache Rust files uses: swatinem/rust-cache@v2 - name: Doc run: cargo doc --no-deps --all-features --document-private-items env: RUSTDOCFLAGS: -D warnings test: name: Test - ${{ matrix.platform.name }} strategy: fail-fast: false matrix: # Run tests on every platform. This is a subset of the Rust targets we # support, just one per OS. platform: - name: Linux os: ubuntu-latest target: x86_64-unknown-linux-gnu - name: Windows os: windows-latest target: x86_64-pc-windows-msvc - name: macOS os: macOS-latest target: aarch64-apple-darwin runs-on: ${{ matrix.platform.os }} steps: - uses: actions/checkout@v4 - name: Cache Rust files uses: swatinem/rust-cache@v2 with: key: ${{ matrix.platform.target }} - name: Install toolchain run: rustup target add ${{ matrix.platform.target }} - name: Run tests run: cargo test --workspace editor-command-2.0.0/.gitignore000064400000000000000000000000101046102023000145040ustar 00000000000000/target editor-command-2.0.0/.rustfmt.toml000064400000000000000000000001601046102023000152010ustar 00000000000000max_width = 80 # these two are only available on nightly (F) imports_granularity = "crate" wrap_comments = true editor-command-2.0.0/CHANGELOG.md000064400000000000000000000016061046102023000143410ustar 00000000000000# Changelog All user-facing changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - ReleaseDate ## [2.0.0] - 2025-10-07 ### Breaking - `EditorBuilder::build` now returns an `Editor` instead of a command - Use `Editor::open` to get the command - `EditorBuilder::path` and `EditorBuilder::paths` have been removed in favor of `Editor::open` - There is no longer a way to open more than one path at a time - `EditorBuilder::source` renamed to `EditorBuilder::string` ### Added - Add `Editor::open_at`, which opens to a specific line/column ## [1.0.0] - 2024-12-27 ### Changed - Replace `shellish_parse` with `shell-words` for parsing ## [0.1.1] - 2024-09-05 ## [0.1.0] - 2024-08-16 Initial release! editor-command-2.0.0/Cargo.toml0000644000000016520000000000100117370ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.70.0" name = "editor-command" version = "2.0.0" authors = ["Lucas Pickering "] description = "Open files in a user's configured editor" readme = "README.md" license = "MIT" repository = "https://github.com/LucasPickering/editor-command" [dependencies.shell-words] version = "1.1.0" [dev-dependencies.env-lock] version = "0.1.0" [dev-dependencies.rstest] version = "0.26.1" default-features = false editor-command-2.0.0/Cargo.toml.orig000064400000000000000000000011631046102023000154150ustar 00000000000000[package] authors = ["Lucas Pickering "] description = "Open files in a user's configured editor" edition = "2021" license = "MIT" name = "editor-command" repository = "https://github.com/LucasPickering/editor-command" rust-version = "1.70.0" version = "2.0.0" [dependencies] shell-words = "1.1.0" [dev-dependencies] env-lock = "0.1.0" rstest = {version = "0.26.1", default-features = false} [workspace.metadata.release] pre-release-replacements = [ {file = "CHANGELOG.md", search = "## \\[Unreleased\\] - ReleaseDate", replace = "## [Unreleased] - ReleaseDate\n\n## [{{version}}] - {{date}}"}, ] editor-command-2.0.0/LICENSE000064400000000000000000000020601046102023000135300ustar 00000000000000MIT License Copyright (c) 2024 Lucas Pickering Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. editor-command-2.0.0/README.md000064400000000000000000000012231046102023000140020ustar 00000000000000# editor-command [![Test CI](https://github.com/github/docs/actions/workflows/test.yml/badge.svg)](https://github.com/LucasPickering/editor-command/actions) [![crates.io](https://img.shields.io/crates/v/editor-command.svg)](https://crates.io/crates/editor-command) [![docs.rs](https://img.shields.io/docsrs/editor-command)](https://docs.rs/editor-command) Load a user's preferred file editing command from the `VISUAL` or `EDITOR` environment variables. ```rust use editor_command::EditorCommand; use std::process::Command; std::env::set_var("VISUAL", "vim"); let mut command: Command = EditorCommand::edit_file("file.txt").unwrap(); command.spawn(); ``` editor-command-2.0.0/mise.toml000064400000000000000000000002051046102023000143540ustar 00000000000000[tasks.doc] sources = ["Cargo.toml", "src/**"] run = "cargo doc" [tasks.test] sources = ["Cargo.toml", "src/**"] run = "cargo test" editor-command-2.0.0/rust-toolchain.toml000064400000000000000000000002011046102023000163660ustar 00000000000000[toolchain] # 1.74 need for cargo --keep-going, for rust-analyzer channel = "1.74.0" components = ["cargo", "clippy", "rustfmt"] editor-command-2.0.0/src/lib.rs000064400000000000000000000526221046102023000144370ustar 00000000000000//! Get an executable [Command] to open a particular file in the user's //! configured editor. //! //! ## Features //! //! - Load editor command from the `VISUAL` or `EDITOR` environment variables //! - Specify high-priority override and low-priority default commands to use //! - Open files to a particular line/column //! - Flexible builder pattern //! //! ## Examples //! //! `editor-command` uses a two-stage abstraction: //! //! - Build an [Editor] (optionally using an [EditorBuilder]), which represents //! a user's desired editor //! - Use [Editor::open] to build a [Command] that will open a particular file //! //! ### Simplest Usage //! //! ``` //! # let _guard = env_lock::lock_env([ //! # ("VISUAL", None::<&str>), //! # ("EDITOR", None), //! # ]); //! use editor_command::Editor; //! use std::process::Command; //! //! std::env::set_var("VISUAL", "vim"); //! // Building an editor is fallible because the user's configured command may //! // be invalid (e.g. it could have unclosed quotes) //! let editor = Editor::new().unwrap(); //! // Once we have an editor, building a Command is infallible //! let command: Command = editor.open("file.txt"); //! //! assert_eq!(command.get_program(), "vim"); //! assert_eq!(command.get_args().collect::>(), &["file.txt"]); //! //! // You can spawn the editor with: //! // command.status().unwrap(); //! ``` //! //! ### Open to Line/Column //! //! You can open a file to particular line/column using [Editor::open_at]: //! //! ``` //! # let _guard = env_lock::lock_env([ //! # ("VISUAL", None::<&str>), //! # ("EDITOR", None), //! # ]); //! use editor_command::Editor; //! use std::process::Command; //! //! std::env::set_var("VISUAL", "vim"); //! let editor = Editor::new().unwrap(); //! let command: Command = editor.open_at("file.txt", 10, 5); //! //! assert_eq!(command.get_program(), "vim"); //! assert_eq!( //! command.get_args().collect::>(), //! &["file.txt", "+call cursor(10, 5)"], //! ); //! ``` //! //! See [Editor::open_at] for info on how it supports line/column for various //! editors, and how to support it for arbitrary user-provided commands. //! //! ### Overrides and Fallbacks //! //! Here's an example of using [EditorBuilder] to provide both an override //! and a fallback command: //! //! ``` //! # let _guard = env_lock::lock_env([ //! # ("VISUAL", None::<&str>), //! # ("EDITOR", None), //! # ]); //! use editor_command::EditorBuilder; //! use std::process::Command; //! //! std::env::set_var("VISUAL", "vim"); // This gets overridden //! let editor = EditorBuilder::new() //! // In this case, the override is always populated so it will always win. //! // In reality it would be an optional user-provided field. //! .string(Some("code --wait")) //! .environment() //! // If both VISUAL and EDITOR are undefined, we'll fall back to this //! .string(Some("vi")) //! .build() //! .unwrap(); //! let command = editor.open("file.txt"); //! //! assert_eq!(command.get_program(), "code"); //! assert_eq!(command.get_args().collect::>(), &["--wait", "file.txt"]); //! ``` //! //! This pattern is useful for apps that have a way to configure an app-specific //! editor. For example, [git has the `core.editor` config field](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration). //! //! ### Tokio //! //! [Editor] returns a `std` [Command], which will execute synchronously. //! If you want to run your editor subprocess asynchronously via //! [tokio](https://docs.rs/tokio/latest/tokio/), use the //! `From` impl on `tokio::process::Command`. For //! example: //! //! ```ignore //! let editor = Editor::new().unwrap(); //! let command: tokio::process::Command = editor.open("file.yaml").into(); //! ``` //! //! ## Syntax //! //! The syntax of the command is meant to resemble command syntax for common //! shells. The first word is the program name, and subsequent tokens (separated //! by spaces) are arguments to that program. Single and double quotes can be //! used to join multiple tokens together into a single argument. //! //! Command parsing is handled by the crate [shell-words](shell_words). Refer to //! those docs for exact details on the syntax. //! //! ## Resources //! //! For more information on the `VISUAL` and `EDITOR` environment variables, //! [check out this thread](https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference). use std::{ borrow::Cow, env, error::Error, fmt::{self, Display}, path::Path, process::Command, str::FromStr, }; /// An editor is a builder for [Command]s. An `Editor` instance represent's a /// user's desired editor, and can be used repeatedly to open files. #[derive(Clone, Debug)] pub struct Editor { /// Binary to invoke program: String, known: Option, /// Arguments to pass to the binary arguments: Vec, } impl Editor { /// Create an editor from the user's `$VISUAL` or `$EDITOR` environment /// variables. This is the easiest way to create an editor, but provides no /// flexibility. See the [crate-level /// documentation](crate#overrides-and-fallbacks) for an example of how /// to use [EditorBuilder] to customize overrides and fallbacks. /// /// ```no_run /// # use editor_command::{Editor, EditorBuilder}; /// Editor::new().unwrap(); /// // is equivalent to /// EditorBuilder::new().environment().build().unwrap(); /// ``` /// /// ### Errors /// /// Returns an error if: /// - Neither `$VISUAL` nor `$EDITOR` is defined /// - The command fails to parse (e.g. dangling quote) pub fn new() -> Result { EditorBuilder::new().environment().build() } /// Build a command that will open a file pub fn open(&self, path: impl AsRef) -> Command { let mut command = Command::new(&self.program); command.args(&self.arguments).arg(path.as_ref()); command } /// Build a command that will open a file to a particular line and column. /// /// Most editors accept the format `path:line:column`, so that's used by /// default. This method supports some specific editors that don't follow /// that convention. It will automatically detect these editors based on the /// invoked command and pass the line/column accordingly: /// /// - `emacs` /// - `vi`/`vim`/`nvim` /// - `nano` (column not supported) /// /// If you want support for another editor that's not listed here, please /// [open an issue on GitHub](https://github.com/LucasPickering/editor-command/issues/new/choose). pub fn open_at( &self, path: impl AsRef, line: u32, column: u32, ) -> Command { let path = path.as_ref(); let mut command = Command::new(&self.program); command.args(&self.arguments); if let Some(known) = self.known { // This editor requires special logic to open to a line/col known.open_at(&mut command, path, line, column); } else { // This is a common format, so hope the editor supports it command .arg(format!("{path}:{line}:{column}", path = path.display())); } command } } /// A builder for customizing an [Editor]. In simple cases you can just use /// [Editor::new] and don't have to interact with this struct. See [crate-level /// documentation](crate#overrides-and-fallbacks) for more details and examples. /// /// ## Example /// /// The builder works by calling one or more "source" methods. Each source may /// (or may not) provide an editor command. The first source that provides a /// command will be used, and subsequent sources will be ignored. For example, /// here's a builder that uses 3 sources: /// /// - User's configured editor /// - Environment variables /// - Static fallback /// /// ``` /// # let _guard = env_lock::lock_env([ /// # ("VISUAL", None::<&str>), /// # ("EDITOR", None), /// # ]); /// use editor_command::EditorBuilder; /// use std::process::Command; /// /// std::env::set_var("VISUAL", "vim"); // This gets overridden /// let editor = EditorBuilder::new() /// .string(configured_editor()) /// .environment() /// // If both VISUAL and EDITOR are undefined, we'll fall back to this /// .string(Some("vi")) /// .build() /// .unwrap(); /// let command = editor.open("file.txt"); /// /// assert_eq!(command.get_program(), "code"); /// assert_eq!(command.get_args().collect::>(), &["--wait", "file.txt"]); /// /// fn configured_editor() -> Option { /// // In reality this would load from a config file or similar /// Some("code --wait".into()) /// } /// ``` /// /// ## Lifetimes /// /// [EditorBuilder] accepts a lifetime parameter, which is bound to the string /// data it contains (both command strings and paths). This is to prevent /// unnecessary cloning when building editors from `&str`s. If you need /// the instance of [EditorBuilder] to be `'static`, e.g. so it can be returned /// from a function, you can simply use `EditorBuilder<'static>`. Internally, /// all strings are stored as [Cow]s, so clones will be made as necessary. Once /// the builder is converted into an [Editor], all strings will be cloned. /// /// ```rust /// use editor_command::EditorBuilder; /// /// /// This is a contrived example of returning a command with owned data /// fn get_editor_builder<'a>(command: &'a str) -> EditorBuilder<'static> { /// // The lifetime bounds enforce the .to_owned() call /// EditorBuilder::new().string(Some(command.to_owned())) /// } /// /// let editor = get_editor_builder("vim").build().unwrap(); /// assert_eq!(editor.open("file").get_program(), "vim"); /// ``` #[derive(Clone, Debug, Default)] pub struct EditorBuilder<'a> { /// Command to parse. This will be populated the first time we're given a /// source with a value. After that, it remains unchanged. command: Option>, } impl<'a> EditorBuilder<'a> { /// Create a new editor command with no sources. You probably want to call /// [environment](Self::environment) on the returned value. pub fn new() -> Self { Self::default() } /// Load the editor command from a string. This is useful for static /// defaults or external sources such as a configuration file. This accepts /// an `Option` so you can easily build a chain of sources that may or may /// not be defined. pub fn string(mut self, source: Option>>) -> Self { self.command = self.command.or(source.map(Into::into)); self } /// Load the editor command from the `VISUAL` and `EDITOR` environment /// variables, in that order. The variables will be evaluated immediately, /// *not* during [build](Self::build). pub fn environment(mut self) -> Self { // Populate command if it isn't already self.command = self .command .or_else(|| env::var("VISUAL").ok().map(Cow::from)) .or_else(|| env::var("EDITOR").ok().map(Cow::from)); self } /// Search all configured sources (in their order of definition), and parse /// the first one that's populated as a shell command. Then use that to /// build an executable [Command]. pub fn build(self) -> Result { // Find the first source that has a value. We *don't* validate that the // command is non-empty or parses. If something has a value, it's better // to use it and give the user an error if it's invalid, than to // silently skip past it. let command_str = self.command.ok_or(EditorBuilderError::NoCommand)?; // Parse it as a shell command let mut parsed = shell_words::split(&command_str) .map_err(EditorBuilderError::ParseError)?; // First token is the program name, rest are arguments let mut tokens = parsed.drain(..); let program = tokens.next().ok_or(EditorBuilderError::EmptyCommand)?; let arguments = tokens.collect(); // Check the program name to see if we recognize this editor let known = program.parse().ok(); Ok(Editor { program, known, arguments, }) } } /// Any error that can occur while loading the editor command. #[derive(Debug)] pub enum EditorBuilderError { /// Couldn't find an editor command anywhere NoCommand, /// The editor command was found, but it's just an empty/whitespace string EmptyCommand, /// Editor command couldn't be parsed in a shell-like format ParseError(shell_words::ParseError), } impl Display for EditorBuilderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { EditorBuilderError::NoCommand => write!( f, "Edit command not defined in any of the listed sources" ), EditorBuilderError::EmptyCommand => { write!(f, "Editor command is empty") } EditorBuilderError::ParseError(source) => { write!(f, "Invalid editor command: {source}") } } } } impl Error for EditorBuilderError { fn source(&self) -> Option<&(dyn Error + 'static)> { match self { EditorBuilderError::NoCommand | EditorBuilderError::EmptyCommand => None, EditorBuilderError::ParseError(source) => Some(source), } } } /// A known editor that requires special logic to open to a line/column. Most /// editors support the common `path:line:column` format and don't need to be /// specified here. #[derive(Copy, Clone, Debug)] enum KnownEditor { Emacs, Nano, /// Also includes Vim and Neovim Vi, // If you add a variant here, make sure to update the docs on open_at } impl KnownEditor { // It'd be nice to use strum for this but I don't want it in the dep tree /// All variants of the enum const ALL: &'static [Self] = &[Self::Emacs, Self::Nano, Self::Vi]; /// Add arguments to the given command to open a file at a particular /// line+column fn open_at( &self, command: &mut Command, path: &Path, line: u32, column: u32, ) { match self { KnownEditor::Emacs => { // Offset has to go first command.arg(format!("+{line}:{column}")).arg(path); } // From my 6 seconds of research, nano doesn't support column KnownEditor::Nano => { // Offset has to go first command.arg(format!("+{line}")).arg(path); } KnownEditor::Vi => { command .arg(path) .arg(format!("+call cursor({line}, {column})")); } } } fn programs(&self) -> &'static [&'static str] { match self { Self::Emacs => &["emacs"], Self::Nano => &["nano"], Self::Vi => &["vi", "vim", "nvim"], } } } impl FromStr for KnownEditor { type Err = (); fn from_str(s: &str) -> Result { Self::ALL .iter() // Intentionally do a case-sensitive match, because binary names // are case-sensitive on some systems .find(|known| known.programs().contains(&s)) .copied() .ok_or(()) } } #[cfg(test)] mod tests { use super::*; use rstest::rstest; use std::path::PathBuf; /// Test loading from a static source that overrides the environment #[test] fn source_priority() { let editor = { let _guard = env_lock::lock_env([ ("VISUAL", Some("visual")), ("EDITOR", Some("editor")), ]); EditorBuilder::new() .string(None::<&str>) .string(Some("priority")) .environment() .string(Some("default")) .build() .unwrap() }; assert_cmd(editor.open("file"), "priority", &["file"]); } /// Test loading from the `VISUAL` env var #[test] fn source_visual() { let editor = { let _guard = env_lock::lock_env([ ("VISUAL", Some("visual")), ("EDITOR", Some("editor")), ]); EditorBuilder::new() .environment() .string(Some("default")) .build() .unwrap() }; assert_cmd(editor.open("file"), "visual", &["file"]); } /// Test loading from the `EDITOR` env var #[test] fn source_editor() { let editor = { let _guard = env_lock::lock_env([ ("VISUAL", None), ("EDITOR", Some("editor")), ]); EditorBuilder::new() .environment() .string(Some("default")) .build() .unwrap() }; assert_cmd(editor.open("file"), "editor", &["file"]); } /// Test loading from a fallback value, with lower precedence than the env #[test] fn source_default() { let editor = { let _guard = env_lock::lock_env([ ("VISUAL", None::<&str>), ("EDITOR", None), ]); EditorBuilder::new() .environment() .string(Some("default")) .build() .unwrap() }; assert_cmd(editor.open("file"), "default", &["file"]); } /// Test `open()` for known and unknown editors #[rstest] #[case::emacs("emacs", "emacs", &["file"])] #[case::nano("nano", "nano", &["file"])] #[case::vi("vi", "vi", &["file"])] #[case::vi_with_args("vi -b", "vi", &["-b", "file"])] #[case::vim("vim", "vim", &["file"])] #[case::neovim("nvim", "nvim", &["file"])] #[case::unknown("unknown --arg", "unknown", &["--arg", "file"])] fn open( #[case] command: &str, #[case] expected_program: &str, #[case] expected_args: &[&str], ) { let editor = EditorBuilder::new().string(Some(command)).build().unwrap(); assert_cmd(editor.open("file"), expected_program, expected_args); } /// Test `open_at()` for known and unknown editors #[rstest] #[case::emacs("emacs", "emacs", &["+2:3", "file"])] // Nano doesn't support column #[case::nano("nano", "nano", &["+2", "file"])] #[case::vi("vi", "vi", &["file", "+call cursor(2, 3)"])] #[case::vi_with_args("vi -b", "vi", &["-b", "file", "+call cursor(2, 3)"])] #[case::vim("vim", "vim", &["file", "+call cursor(2, 3)"])] #[case::neovim("nvim", "nvim", &["file", "+call cursor(2, 3)"])] // Default to path:line:column #[case::unknown("unknown --arg", "unknown", &["--arg", "file:2:3"])] fn open_at( #[case] command: &str, #[case] expected_program: &str, #[case] expected_args: &[&str], ) { let editor = EditorBuilder::new().string(Some(command)).build().unwrap(); assert_cmd( editor.open_at("file", 2, 3), expected_program, expected_args, ); } /// Test included paths as extra arguments #[test] fn paths() { let editor = EditorBuilder::new().string(Some("ed")).build().unwrap(); // All of these types should be accepted, for ergonomics assert_cmd(editor.open("str"), "ed", &["str"]); assert_cmd(editor.open(Path::new("path")), "ed", &["path"]); assert_cmd(editor.open(PathBuf::from("pathbuf")), "ed", &["pathbuf"]); } /// Test simple command parsing logic. We'll defer edge cases to shell-words #[test] fn parsing() { let editor = EditorBuilder::new() .string(Some("ned '--single \" quotes' \"--double ' quotes\"")) .build() .unwrap(); assert_cmd( editor.open("file"), "ned", &["--single \" quotes", "--double ' quotes", "file"], ); } /// Test when all options are undefined #[test] fn error_no_command() { let _guard = env_lock::lock_env([ ("VISUAL", None::<&str>), ("EDITOR", None::<&str>), ]); assert_err( EditorBuilder::new().environment().string(None::<&str>), "Edit command not defined in any of the listed sources", ); } /// Test when the command exists but is the empty string #[test] fn error_empty_command() { assert_err( EditorBuilder::new().string(Some("")), "Editor command is empty", ); } /// Test when a value can't be parsed as a command string #[test] fn error_invalid_command() { assert_err( EditorBuilder::new().string(Some("'unclosed quote")), "Invalid editor command: missing closing quote", ); } /// Assert that the editor creates the expected command #[track_caller] fn assert_cmd( command: Command, expected_program: &str, expected_args: &[&str], ) { assert_eq!(command.get_program(), expected_program); assert_eq!(command.get_args().collect::>(), expected_args); } /// Assert that the builder fails to build with the given error message #[track_caller] fn assert_err(builder: EditorBuilder, expected_error: &str) { let error = builder.build().unwrap_err(); assert_eq!(error.to_string(), expected_error); } }