use similar::{ChangeTag, TextDiff};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub struct EditorResult {
pub success: bool,
pub changes: Vec<LineChange>,
pub error: Option<String>,
}
#[derive(Debug, Clone)]
pub struct LineChange {
pub line_number: usize,
pub old_content: String,
pub new_content: String,
}
pub fn open_in_editor(file: &Path, line: usize, column: Option<usize>) -> EditorResult {
let original_content = match fs::read_to_string(file) {
Ok(content) => content,
Err(e) => {
return EditorResult {
success: false,
changes: vec![],
error: Some(format!("Failed to read file: {}", e)),
}
}
};
let editor = get_editor();
let mut cmd = build_editor_command(&editor, file, line, column);
let spawn_result = cmd.spawn();
match spawn_result {
Ok(child) => wait_for_editor(child, &editor, file, &original_content),
Err(e) => EditorResult {
success: false,
changes: vec![],
error: Some(format!("Failed to launch editor '{}': {}", editor, e)),
},
}
}
fn build_editor_command(
editor: &str,
file: &Path,
line: usize,
column: Option<usize>,
) -> Command {
let editor_lower = editor.to_lowercase();
let mut cmd = Command::new(editor);
let editor_name = Path::new(&editor_lower)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&editor_lower);
match editor_name {
"code" | "code-insiders" | "codium" => {
let col = column.unwrap_or(1);
cmd.arg("--goto")
.arg(format!("{}:{}:{}", file.display(), line, col));
}
"vim" | "nvim" | "vi" | "gvim" | "mvim" | "emacs" | "emacsclient" | "nano" | "kak" => {
cmd.arg(format!("+{}", line)).arg(file);
}
"sublime" | "subl" | "sublime_text" | "atom" | "hx" | "helix" => {
let col = column.unwrap_or(1);
cmd.arg(format!("{}:{}:{}", file.display(), line, col));
}
"notepad++" => {
cmd.arg(format!("-n{}", line)).arg(file);
}
name if name.contains("idea") || name.contains("goland") || name.contains("pycharm") => {
let col = column.unwrap_or(1);
cmd.arg("--line")
.arg(line.to_string())
.arg("--column")
.arg(col.to_string())
.arg(file);
}
_ => {
cmd.arg(format!("+{}", line)).arg(file);
}
}
cmd
}
fn wait_for_editor(
mut child: std::process::Child,
editor: &str,
file: &Path,
original_content: &str,
) -> EditorResult {
match child.wait() {
Ok(status) => {
if !status.success() {
return EditorResult {
success: false,
changes: vec![],
error: Some(format!(
"Editor '{}' exited with status: {}",
editor,
status.code().unwrap_or(-1)
)),
};
}
let new_content = match fs::read_to_string(file) {
Ok(content) => content,
Err(e) => {
return EditorResult {
success: false,
changes: vec![],
error: Some(format!("Failed to read file after editing: {}", e)),
}
}
};
let changes = detect_changes(original_content, &new_content);
EditorResult {
success: true,
changes,
error: None,
}
}
Err(e) => EditorResult {
success: false,
changes: vec![],
error: Some(format!("Failed to wait for editor '{}': {}", editor, e)),
},
}
}
fn detect_changes(original: &str, new: &str) -> Vec<LineChange> {
let mut changes = Vec::new();
let diff = TextDiff::from_lines(original, new);
let mut new_line_num = 0;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
new_line_num += 1;
}
ChangeTag::Delete => {
changes.push(LineChange {
line_number: new_line_num + 1, old_content: change.to_string().trim_end().to_string(),
new_content: String::new(), });
}
ChangeTag::Insert => {
new_line_num += 1;
changes.push(LineChange {
line_number: new_line_num,
old_content: String::new(), new_content: change.to_string().trim_end().to_string(),
});
}
}
}
changes
}
fn get_editor() -> String {
if let Ok(editor) = std::env::var("EDITOR") {
if !editor.is_empty() {
return editor;
}
}
if let Ok(visual) = std::env::var("VISUAL") {
if !visual.is_empty() {
return visual;
}
}
#[cfg(windows)]
{
for editor in &["code", "notepad++", "notepad"] {
if which_exists(editor) {
return editor.to_string();
}
}
"notepad".to_string()
}
#[cfg(not(windows))]
{
"vim".to_string()
}
}
#[cfg(windows)]
fn which_exists(cmd: &str) -> bool {
use std::process::Stdio;
Command::new("where")
.arg(cmd)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_editor_default() {
let editor = get_editor();
assert!(!editor.is_empty());
}
}