use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorTarget {
pub path: PathBuf,
pub line: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorCommand {
pub program: String,
pub args: Vec<OsString>,
}
impl EditorCommand {
pub fn from_env(target: &EditorTarget) -> Self {
let editor = std::env::var("EDITOR").unwrap_or_default();
Self::from_editor(&editor, target)
}
pub fn from_editor(editor: &str, target: &EditorTarget) -> Self {
let mut parts = shlex::split(editor)
.filter(|parts| !parts.is_empty())
.unwrap_or_else(|| vec!["vi".to_string()]);
let program = parts.remove(0);
let mut args: Vec<OsString> = parts.into_iter().map(OsString::from).collect();
match (editor_family(&program), target.line) {
(EditorFamily::PlusLine, Some(line)) => {
args.push(OsString::from(format!("+{line}")));
args.push(target.path.as_os_str().to_os_string());
}
(EditorFamily::GotoLine, Some(line)) => {
args.push(OsString::from("--goto"));
args.push(OsString::from(format!("{}:{line}", target.path.display())));
}
_ => args.push(target.path.as_os_str().to_os_string()),
}
Self { program, args }
}
pub fn run(&self) -> std::io::Result<std::process::ExitStatus> {
Command::new(&self.program).args(&self.args).status()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EditorFamily {
PlusLine,
GotoLine,
Plain,
}
fn editor_family(program: &str) -> EditorFamily {
let name = Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(program);
match name {
"vi" | "vim" | "nvim" | "nano" => EditorFamily::PlusLine,
"code" | "code-insiders" | "codium" | "cursor" => EditorFamily::GotoLine,
_ => EditorFamily::Plain,
}
}
#[derive(Debug, thiserror::Error)]
pub enum EditorError {
#[error("Failed to launch editor: {0}")]
Launch(#[source] std::io::Error),
#[error("Editor exited with status {}", status_label(.0))]
Exit(ExitStatus),
}
fn status_label(status: &ExitStatus) -> String {
status
.code()
.map(|code| code.to_string())
.unwrap_or_else(|| "signal".to_string())
}
pub fn run_editor(target: &EditorTarget) -> Result<(), EditorError> {
let command = EditorCommand::from_env(target);
match command.run() {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(EditorError::Exit(status)),
Err(err) => Err(EditorError::Launch(err)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn target(line: Option<u32>) -> EditorTarget {
EditorTarget {
path: PathBuf::from("/repo/src/main.rs"),
line,
}
}
fn args(command: &EditorCommand) -> Vec<String> {
command
.args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
#[test]
fn plus_line_editors_receive_line_before_path() {
for editor in ["vi", "vim", "nvim", "nano"] {
let command = EditorCommand::from_editor(editor, &target(Some(42)));
assert_eq!(command.program, editor);
assert_eq!(args(&command), vec!["+42", "/repo/src/main.rs"]);
}
}
#[test]
fn vscode_family_receives_goto_arg() {
for editor in ["code", "code-insiders", "codium", "cursor"] {
let command = EditorCommand::from_editor(editor, &target(Some(42)));
assert_eq!(command.program, editor);
assert_eq!(args(&command), vec!["--goto", "/repo/src/main.rs:42"]);
}
}
#[test]
fn unknown_editor_opens_file_without_line() {
let command = EditorCommand::from_editor("zed", &target(Some(42)));
assert_eq!(command.program, "zed");
assert_eq!(args(&command), vec!["/repo/src/main.rs"]);
}
#[test]
fn editor_args_are_preserved() {
let command = EditorCommand::from_editor("vim -f", &target(Some(42)));
assert_eq!(command.program, "vim");
assert_eq!(args(&command), vec!["-f", "+42", "/repo/src/main.rs"]);
}
#[test]
fn empty_editor_falls_back_to_vi() {
let command = EditorCommand::from_editor("", &target(None));
assert_eq!(command.program, "vi");
assert_eq!(args(&command), vec!["/repo/src/main.rs"]);
}
}