use std::{env, path::Path};
use eyre::{Result, WrapErr, bail, eyre};
use tokio::process::Command;
#[derive(Clone, Copy, Debug, Default)]
pub struct Position {
pub line: u32,
pub col: Option<u32>,
}
impl Position {
pub fn new(line: u32, col: Option<u32>) -> Self {
Self { line, col }
}
}
#[derive(Debug, Default)]
pub enum OpenMode {
#[default]
Normal,
Force,
Read,
Pager,
}
#[derive(Debug, Default)]
pub struct Client {
git: bool,
mode: OpenMode,
position: Option<Position>,
buffer: Option<String>,
}
impl Client {
pub fn git(mut self, enable: bool) -> Self {
self.git = enable;
self
}
pub fn mode(mut self, mode: OpenMode) -> Self {
self.mode = mode;
self
}
pub fn at(mut self, position: Position) -> Self {
self.position = Some(position);
self
}
pub fn with_buffer(mut self, contents: String) -> Self {
self.buffer = Some(contents);
self
}
pub async fn open<P: AsRef<Path>>(self, path: P) -> Result<bool> {
let path = path.as_ref();
if self.git { self.open_with_git(path).await } else { self.open_file(path).await }
}
async fn open_file(self, path: &Path) -> Result<bool> {
let p = path.display();
let editor = Editor::detect();
let opts = OpenOptions {
position: self.position,
buffer: self.buffer.as_deref(),
};
let mtime_before = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
match self.mode {
OpenMode::Normal => {
if !path.exists() {
bail!("File does not exist");
}
let cmd = editor.format_open_cmd(path, &opts)?;
Command::new("sh").arg("-c").arg(cmd).status().await.map_err(|_| eyre!("$EDITOR env variable is not defined"))?;
}
OpenMode::Force => {
let cmd = editor.format_open_cmd(path, &opts)?;
Command::new("sh")
.arg("-c")
.arg(cmd)
.status()
.await
.map_err(|_| eyre!("$EDITOR env variable is not defined or permission lacking to create the file: {p}"))?;
}
OpenMode::Pager => {
if !path.exists() {
bail!("File does not exist");
}
Command::new("sh").arg("-c").arg(format!("less {p}")).status().await?;
}
OpenMode::Read => {
if !path.exists() {
bail!("File does not exist");
}
Command::new("sh")
.arg("-c")
.arg(format!("nvim -R {p}"))
.status()
.await
.map_err(|_| eyre!("nvim is not found in path"))?;
}
}
let mtime_after = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
let modified = match (mtime_before, mtime_after) {
(None, Some(_)) => true, (Some(before), Some(after)) => after != before,
_ => false,
};
Ok(modified)
}
async fn open_with_git(self, path: &Path) -> Result<bool> {
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(e) => match self.mode {
OpenMode::Force => {
std::fs::File::create(path).with_context(|| format!("Failed to force-create file at '{}'.\n{e}", path.display()))?;
std::fs::metadata(path).unwrap()
}
_ => eyre::bail!(
"Failed to read metadata of file/directory at '{}', which means we do not have sufficient permissions or it does not exist",
path.display()
),
},
};
let sp = match metadata.is_dir() {
true => path.display(),
false => path.parent().unwrap().display(),
};
Command::new("sh").arg("-c").arg(format!("git -C \"{sp}\" pull")).status().await.with_context(|| {
format!("Failed to pull from Git repository at '{sp}'. Ensure a repository exists at this path or any of its parent directories and no merge conflicts are present.")
})?;
let modified = self
.open_file(path)
.await
.with_context(|| format!("Failed to open file at '{}'. Use `OpenMode::Force` and ensure you have necessary permissions", path.display()))?;
Command::new("sh")
.arg("-c")
.arg(format!("git -C \"{sp}\" add -A && git -C \"{sp}\" commit -m \".\" && git -C \"{sp}\" push"))
.status()
.await
.with_context(|| format!("Failed to commit or push to Git repository at '{sp}'. Ensure you have the necessary permissions and the repository is correctly configured."))?;
Ok(modified)
}
}
pub async fn open<P: AsRef<Path>>(path: P) -> Result<bool> {
Client::default().open(path).await
}
pub fn open_blocking<P: AsRef<Path>>(path: P) -> Result<bool> {
tokio::runtime::Runtime::new().unwrap().block_on(open(path))
}
#[derive(Debug, Default)]
struct OpenOptions<'a> {
position: Option<Position>,
buffer: Option<&'a str>,
}
#[derive(Clone, Copy, Debug)]
enum Editor {
Nvim,
Helix,
Vscode,
Unknown,
}
impl Editor {
fn detect() -> Self {
let editor = env::var("EDITOR").unwrap_or_default();
let editor_name = Path::new(&editor).file_name().and_then(|s| s.to_str()).unwrap_or(&editor);
match editor_name {
"nvim" | "vim" | "vi" => Self::Nvim,
"hx" | "helix" => Self::Helix,
"code" | "code-insiders" => Self::Vscode,
_ => Self::Unknown,
}
}
fn format_open_cmd(&self, path: &Path, opts: &OpenOptions) -> Result<String> {
let p = path.display();
if let Some(contents) = opts.buffer {
return match self {
Self::Nvim => {
let escaped = contents.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
Ok(format!(
r#"nvim -c "lua vim.api.nvim_buf_set_lines(0, 0, -1, false, vim.split(\"{escaped}\", '\\n')); vim.bo.modified = true" "{p}""#
))
}
_ => bail!("with_buffer() only supported for nvim"),
};
}
Ok(match (self, opts.position) {
(Self::Nvim, Some(pos)) => {
match pos.col {
Some(col) => format!("$EDITOR \"+call cursor({}, {col}) | normal zz\" \"{p}\"", pos.line),
None => format!("$EDITOR \"+call cursor({}, 1) | normal zz\" \"{p}\"", pos.line),
}
}
(Self::Helix, Some(pos)) => {
match pos.col {
Some(col) => format!("$EDITOR \"{p}:{}:{col}\"", pos.line),
None => format!("$EDITOR \"{p}:{}\"", pos.line),
}
}
(Self::Vscode, Some(pos)) => {
match pos.col {
Some(col) => format!("$EDITOR --goto \"{p}:{}:{col}\"", pos.line),
None => format!("$EDITOR --goto \"{p}:{}\"", pos.line),
}
}
(_, _) => format!("$EDITOR \"{p}\""),
})
}
}