use std::{
borrow::Cow,
env,
error::Error,
fmt::{self, Display},
path::Path,
process::Command,
str::FromStr,
};
#[derive(Clone, Debug)]
pub struct Editor {
program: String,
known: Option<KnownEditor>,
arguments: Vec<String>,
}
impl Editor {
pub fn new() -> Result<Self, EditorBuilderError> {
EditorBuilder::new().environment().build()
}
pub fn open(&self, path: impl AsRef<Path>) -> Command {
let mut command = Command::new(&self.program);
command.args(&self.arguments).arg(path.as_ref());
command
}
pub fn open_at(
&self,
path: impl AsRef<Path>,
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 {
known.open_at(&mut command, path, line, column);
} else {
command
.arg(format!("{path}:{line}:{column}", path = path.display()));
}
command
}
}
#[derive(Clone, Debug, Default)]
pub struct EditorBuilder<'a> {
command: Option<Cow<'a, str>>,
}
impl<'a> EditorBuilder<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn string(mut self, source: Option<impl Into<Cow<'a, str>>>) -> Self {
self.command = self.command.or(source.map(Into::into));
self
}
pub fn environment(mut self) -> Self {
self.command = self
.command
.or_else(|| env::var("VISUAL").ok().map(Cow::from))
.or_else(|| env::var("EDITOR").ok().map(Cow::from));
self
}
pub fn build(self) -> Result<Editor, EditorBuilderError> {
let command_str = self.command.ok_or(EditorBuilderError::NoCommand)?;
let mut parsed = shell_words::split(&command_str)
.map_err(EditorBuilderError::ParseError)?;
let mut tokens = parsed.drain(..);
let program = tokens.next().ok_or(EditorBuilderError::EmptyCommand)?;
let arguments = tokens.collect();
let known = program.parse().ok();
Ok(Editor {
program,
known,
arguments,
})
}
}
#[derive(Debug)]
pub enum EditorBuilderError {
NoCommand,
EmptyCommand,
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),
}
}
}
#[derive(Copy, Clone, Debug)]
enum KnownEditor {
Emacs,
Nano,
Vi,
}
impl KnownEditor {
const ALL: &'static [Self] = &[Self::Emacs, Self::Nano, Self::Vi];
fn open_at(
&self,
command: &mut Command,
path: &Path,
line: u32,
column: u32,
) {
match self {
KnownEditor::Emacs => {
command.arg(format!("+{line}:{column}")).arg(path);
}
KnownEditor::Nano => {
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, Self::Err> {
Self::ALL
.iter()
.find(|known| known.programs().contains(&s))
.copied()
.ok_or(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use std::path::PathBuf;
#[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]
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]
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]
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"]);
}
#[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);
}
#[rstest]
#[case::emacs("emacs", "emacs", &["+2:3", "file"])]
#[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)"])]
#[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]
fn paths() {
let editor = EditorBuilder::new().string(Some("ed")).build().unwrap();
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]
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]
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]
fn error_empty_command() {
assert_err(
EditorBuilder::new().string(Some("")),
"Editor command is empty",
);
}
#[test]
fn error_invalid_command() {
assert_err(
EditorBuilder::new().string(Some("'unclosed quote")),
"Invalid editor command: missing closing quote",
);
}
#[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::<Vec<_>>(), expected_args);
}
#[track_caller]
fn assert_err(builder: EditorBuilder, expected_error: &str) {
let error = builder.build().unwrap_err();
assert_eq!(error.to_string(), expected_error);
}
}