use std::ffi::OsString;
use std::path::Path;
use crate::{CompatibilityMode, EditorSource, Error};
#[derive(Debug, Clone)]
pub struct Resolved {
pub argv: Vec<OsString>,
pub source: EditorSource,
}
pub fn resolve(
explicit_override: Option<&str>,
env_visual: Option<&str>,
env_editor: Option<&str>,
mode: CompatibilityMode,
) -> Result<Resolved, Error> {
if mode == CompatibilityMode::Default {
if let Some(cmd) = explicit_override {
if !cmd.is_empty() {
let argv = parse_editor_value(cmd)?;
if !argv.is_empty() {
return Ok(Resolved {
argv,
source: EditorSource::Override(cmd.to_string()),
});
}
}
}
}
if let Some(v) = env_visual {
if !v.is_empty() {
let argv = parse_editor_value(v)?;
if !argv.is_empty() {
return Ok(Resolved {
argv,
source: EditorSource::EnvLookup,
});
}
}
}
if let Some(v) = env_editor {
if !v.is_empty() {
let argv = parse_editor_value(v)?;
if !argv.is_empty() {
return Ok(Resolved {
argv,
source: EditorSource::EnvLookup,
});
}
}
}
#[cfg(unix)]
{
const USR_BIN_EDITOR: &str = "/usr/bin/editor";
if is_unix_executable(Path::new(USR_BIN_EDITOR)) {
return Ok(Resolved {
argv: vec![OsString::from(USR_BIN_EDITOR)],
source: EditorSource::EnvLookup,
});
}
}
#[cfg(unix)]
let fallback = "vi";
#[cfg(windows)]
let fallback = "notepad.exe";
Ok(Resolved {
argv: vec![OsString::from(fallback)],
source: EditorSource::EnvLookup,
})
}
pub fn parse_editor_value(value: &str) -> Result<Vec<OsString>, Error> {
match shell_words::split(value) {
Ok(parts) => Ok(parts.into_iter().map(OsString::from).collect()),
Err(_) => Err(Error::InvalidEditorCommand(value.to_string())),
}
}
#[cfg(unix)]
fn is_unix_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
meta.is_file() && (meta.permissions().mode() & 0o111) != 0
}
#[cfg(not(unix))]
#[allow(dead_code)]
fn is_unix_executable(_path: &Path) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_command() {
let argv = parse_editor_value("vi").unwrap();
assert_eq!(argv, vec![OsString::from("vi")]);
}
#[test]
fn parse_command_with_arg() {
let argv = parse_editor_value("code --wait").unwrap();
assert_eq!(argv, vec![OsString::from("code"), OsString::from("--wait")]);
}
#[test]
fn parse_quoted_path_with_spaces() {
let argv = parse_editor_value(r#""path with spaces/editor" --flag"#).unwrap();
assert_eq!(
argv,
vec![
OsString::from("path with spaces/editor"),
OsString::from("--flag")
]
);
}
#[test]
fn parse_unbalanced_quote_returns_typed_error() {
let raw = r#""unbalanced"#;
match parse_editor_value(raw) {
Err(Error::InvalidEditorCommand(s)) => assert_eq!(s, raw),
other => panic!("expected InvalidEditorCommand, got {other:?}"),
}
}
#[test]
fn resolve_override_wins_in_default_mode() {
let r = resolve(
Some("my-editor"),
Some("/should/be/ignored"),
Some("/also/ignored"),
CompatibilityMode::Default,
)
.unwrap();
assert_eq!(r.argv, vec![OsString::from("my-editor")]);
assert!(matches!(r.source, EditorSource::Override(_)));
}
#[test]
fn resolve_override_ignored_in_strict_mode() {
let r = resolve(
Some("override"),
Some("from-visual"),
None,
CompatibilityMode::Strict,
)
.unwrap();
assert_eq!(r.argv, vec![OsString::from("from-visual")]);
}
#[test]
fn resolve_empty_override_falls_through() {
let r = resolve(
Some(""),
Some("from-visual"),
None,
CompatibilityMode::Default,
)
.unwrap();
assert_eq!(r.argv, vec![OsString::from("from-visual")]);
}
#[test]
fn resolve_visual_wins_over_editor() {
let r = resolve(
None,
Some("from-visual"),
Some("from-editor"),
CompatibilityMode::Default,
)
.unwrap();
assert_eq!(r.argv, vec![OsString::from("from-visual")]);
}
#[test]
fn resolve_editor_used_when_visual_unset() {
let r = resolve(None, None, Some("from-editor"), CompatibilityMode::Default).unwrap();
assert_eq!(r.argv, vec![OsString::from("from-editor")]);
}
#[cfg(unix)]
#[test]
fn resolve_unix_falls_back_to_vi_when_nothing_else() {
let r = resolve(None, None, None, CompatibilityMode::Default).unwrap();
let first = r.argv.first().expect("at least argv[0]");
assert!(
first == &OsString::from("/usr/bin/editor") || first == &OsString::from("vi"),
"Unix fallback should be /usr/bin/editor or vi, got {first:?}"
);
}
#[cfg(windows)]
#[test]
fn resolve_windows_falls_back_to_notepad() {
let r = resolve(None, None, None, CompatibilityMode::Default).unwrap();
assert_eq!(r.argv, vec![OsString::from("notepad.exe")]);
}
}