use std::ffi::OsString;
use std::io::IsTerminal;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process;
use std::{env, fs, io};
pub struct Editor {
path: PathBuf,
truncate: bool,
cleanup: bool,
}
impl Default for Editor {
fn default() -> Self {
Self::comment()
}
}
impl Drop for Editor {
fn drop(&mut self) {
if self.cleanup {
fs::remove_file(&self.path).ok();
}
}
}
impl Editor {
pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
let path = path.as_ref();
if path.try_exists()? {
let meta = fs::metadata(path)?;
if !meta.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"must be used to edit a file",
));
}
}
Ok(Self {
path: path.to_path_buf(),
truncate: false,
cleanup: false,
})
}
pub fn comment() -> Self {
const COMMENT_FILE: &str = "RAD_COMMENT";
let path = env::temp_dir().join(COMMENT_FILE);
Self {
path,
truncate: true,
cleanup: true,
}
}
pub fn extension(mut self, ext: &str) -> Self {
let ext = ext.trim_start_matches('.');
self.path.set_extension(ext);
self
}
pub fn truncate(mut self, truncate: bool) -> Self {
self.truncate = truncate;
self
}
pub fn cleanup(mut self, cleanup: bool) -> Self {
self.cleanup = cleanup;
self
}
#[allow(clippy::byte_char_slices)]
pub fn initial(self, content: impl AsRef<[u8]>) -> io::Result<Self> {
let content = content.as_ref();
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(self.truncate)
.open(&self.path)?;
if file.metadata()?.len() == 0 {
file.write_all(content)?;
if !content.ends_with(&[b'\n']) {
file.write_all(b"\n")?;
}
file.flush()?;
}
Ok(self)
}
pub fn edit(&mut self) -> io::Result<Option<String>> {
let Some(cmd) = self::default_editor() else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"editor not configured: the `EDITOR` environment variable is not set",
));
};
let lossy = cmd.to_string_lossy();
#[cfg(unix)]
let Some(parts) = shlex::split(&lossy) else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid editor command {cmd:?}"),
));
};
#[cfg(windows)]
let parts = winsplit::split(&lossy);
let Some((program, args)) = parts.split_first() else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid editor command {cmd:?}"),
));
};
let stdout: process::Stdio = {
#[cfg(unix)]
{
use std::os::fd::{AsRawFd as _, FromRawFd as _};
let stderr = io::stderr().as_raw_fd();
unsafe { process::Stdio::from_raw_fd(libc::dup(stderr)) }
}
#[cfg(not(unix))]
{
io::stderr().into()
}
};
let stdin = if io::stdin().is_terminal() {
process::Stdio::inherit()
} else if cfg!(unix) {
let tty = fs::OpenOptions::new()
.read(true)
.write(true)
.open("/dev/tty")?;
process::Stdio::from(tty)
} else if cfg!(windows) {
let tty = fs::OpenOptions::new().read(true).open("CONIN$")?;
process::Stdio::from(tty)
} else {
return Err(io::Error::new(
io::ErrorKind::Unsupported,
format!("standard input is not a terminal, refusing to execute editor {cmd:?}"),
));
};
process::Command::new(program)
.stdout(stdout)
.stderr(process::Stdio::inherit())
.stdin(stdin)
.args(args)
.arg(&self.path)
.spawn()
.map_err(|e| {
io::Error::new(
e.kind(),
format!("failed to spawn editor command {cmd:?}: {e}"),
)
})?
.wait()
.map_err(|e| {
io::Error::new(
e.kind(),
format!("editor command {cmd:?} didn't spawn: {e}"),
)
})?;
let text = fs::read_to_string(&self.path)?;
if text.trim().is_empty() {
return Ok(None);
}
Ok(Some(text))
}
}
fn default_editor() -> Option<OsString> {
if let Ok(visual) = env::var("VISUAL") {
if !visual.is_empty() {
return Some(visual.into());
}
}
if let Ok(editor) = env::var("EDITOR") {
if !editor.is_empty() {
return Some(editor.into());
}
}
#[cfg(all(feature = "git2", not(windows)))]
if let Ok(path) = git2::Config::open_default().and_then(|cfg| cfg.get_path("core.editor")) {
return Some(path.into_os_string());
}
#[cfg(target_os = "macos")]
if exists("nano") {
return Some("nano".into());
}
#[cfg(windows)]
if exists("edit.exe") {
return Some("edit.exe".into());
}
#[cfg(windows)]
if exists("notepad.exe") {
return Some("notepad.exe".into());
}
if exists("vi") {
return Some("vi".into());
}
None
}
#[cfg(unix)]
fn exists(cmd: &str) -> bool {
const PATHS: &[&str] = &["/usr/local/bin", "/usr/bin", "/bin"];
for dir in PATHS {
if Path::new(dir).join(cmd).exists() {
return true;
}
}
false
}
#[cfg(windows)]
fn exists(cmd: &str) -> bool {
std::process::Command::new("where.exe")
.arg("/q")
.arg("$PATH:".to_owned() + cmd)
.output()
.map(|output| output.status.success())
.unwrap_or_default()
}