| //! Helper utilities for invoking git configured tools. |
| |
| use std::fs::File; |
| use std::io::{self, BufRead, BufReader, Write}; |
| use std::path::{Path, PathBuf}; |
| use std::process::{Child, Command, ExitStatus, Stdio}; |
| use std::str; |
| |
| use tempfile::NamedTempFile; |
| use tracing::warn; |
| |
| use crate::errors::EditError; |
| use crate::out::Out; |
| |
| #[cfg(windows)] |
| fn git_sh_path() -> Option<PathBuf> { |
| // Locate the `git` binary using the windows `where` command. |
| let output = Command::new("where").arg("git").output().ok()?; |
| if !output.status.success() { |
| return None; |
| } |
| |
| // The git binary path should be either in the `cmd` or `bin` subdirectory |
| // of the git-for-windows install path, while the `sh.exe` binary is located |
| // in the `bin` subdirectory. |
| Path::new(str::from_utf8(&output.stdout).ok()?.trim()) |
| .canonicalize() |
| .ok()? |
| .parent()? |
| .parent()? |
| .join(r"bin\sh.exe") |
| .canonicalize() |
| .ok() |
| } |
| |
| #[cfg(not(windows))] |
| fn git_sh_path() -> Option<PathBuf> { |
| Some("/bin/sh".into()) |
| } |
| |
| fn git_config(config: &str) -> Option<String> { |
| let output = Command::new("git") |
| .arg("config") |
| .arg(config) |
| .output() |
| .ok()?; |
| if !output.status.success() { |
| return None; |
| } |
| Some(str::from_utf8(&output.stdout).ok()?.trim().to_owned()) |
| } |
| |
| fn git_config_bool(config: &str) -> Option<bool> { |
| let value = git_config(config)?; |
| match &value[..] { |
| "true" | "yes" | "on" => Some(true), |
| "" | "false" | "no" | "off" => Some(false), |
| _ => Some(value.parse::<i64>().ok()? != 0), |
| } |
| } |
| |
| /// Read the git configuration to determine the value for GIT_EDITOR. |
| fn git_editor() -> Option<String> { |
| // Testing environment variable to force using the fallback editor instead |
| // of GIT_EDITOR. |
| if std::env::var("CARGO_VET_USE_FALLBACK_EDITOR").unwrap_or_default() == "1" { |
| return None; |
| } |
| |
| let output = Command::new("git") |
| .arg("var") |
| .arg("GIT_EDITOR") |
| .output() |
| .ok()?; |
| if !output.status.success() { |
| return None; |
| } |
| Some(str::from_utf8(&output.stdout).ok()?.trim().to_owned()) |
| } |
| |
| #[cfg(windows)] |
| const FALLBACK_EDITOR: &str = "notepad.exe"; |
| |
| // NOTE: This is probably not as reliably available as `vi`, but is easier to |
| // quit from for users who aren't familiar with vi. |
| #[cfg(not(windows))] |
| const FALLBACK_EDITOR: &str = "nano"; |
| |
| /// Get a Command which can be used to invoke the user's EDITOR to edit a |
| /// document when passed an argument. This will try to use the user's configured |
| /// GIT_EDITOR when possible. |
| pub fn editor_command() -> Command { |
| // Try to use the user's configured editor if we're able to locate their git |
| // install. If this fails, invoke the default editor instead. |
| // |
| // XXX: If we end up with commands which invoke the editor many times, it |
| // may eventually be worth adding some form of caching here. |
| match (git_sh_path(), git_editor()) { |
| (Some(git_sh), Some(git_editor)) => { |
| let mut cmd = Command::new(git_sh); |
| cmd.arg("-c") |
| .arg(format!("{git_editor} \"$@\"")) |
| .arg(git_editor); |
| return cmd; |
| } |
| (_, None) => { |
| warn!("Unable to determine user's GIT_EDITOR"); |
| } |
| (None, Some(_)) => { |
| warn!("Unable to locate user's git install to invoke GIT_EDITOR"); |
| } |
| } |
| warn!("Falling back to running '{}' directly", FALLBACK_EDITOR); |
| Command::new(FALLBACK_EDITOR) |
| } |
| |
| /// Run the default editor configured through git (GIT_EDITOR) and use it to |
| /// edit the given file path. |
| pub fn run_editor(path: &Path) -> io::Result<ExitStatus> { |
| editor_command().arg(path).status() |
| } |
| |
| // On windows some editors (notably notepad pre-windows 11) don't handle |
| // unix line endings very well, so make sure to give them windows line |
| // endings. |
| #[cfg(windows)] |
| const LINE_ENDING: &str = "\r\n"; |
| |
| #[cfg(not(windows))] |
| const LINE_ENDING: &str = "\n"; |
| |
| pub struct Editor<'a> { |
| tempfile: NamedTempFile, |
| comment_char: char, |
| #[allow(clippy::type_complexity)] |
| run_editor: Box<dyn FnOnce(&Path) -> io::Result<bool> + 'a>, |
| } |
| |
| impl<'a> Editor<'a> { |
| /// Create a new editor for a temporary file. |
| pub fn new(name: &str) -> io::Result<Self> { |
| let tempfile = tempfile::Builder::new() |
| .prefix(&format!("{name}.")) |
| .tempfile()?; |
| Ok(Editor { |
| tempfile, |
| comment_char: '#', |
| run_editor: Box::new(|p| run_editor(p).map(|s| s.success())), |
| }) |
| } |
| |
| /// Set the character to be used for comments. |
| pub fn set_comment_char(&mut self, comment_char: char) { |
| self.comment_char = comment_char; |
| } |
| |
| /// Attempt to pick a comment character which does not appear at the start |
| /// of any line in the given text. |
| pub fn select_comment_char(&mut self, text: &str) { |
| let mut comment_chars = ['#', ';', '@', '!', '$', '%', '^', '&', '|', ':', '"', ';']; |
| for line in text.lines() { |
| for cc in &mut comment_chars { |
| if line.starts_with(*cc) { |
| *cc = '\0'; |
| break; |
| } |
| } |
| } |
| self.set_comment_char( |
| comment_chars |
| .into_iter() |
| .find(|cc| *cc != '\0') |
| .expect("couldn't find a viable comment character"), |
| ); |
| } |
| |
| #[cfg(test)] |
| /// Test-only method to mock out the actual invocation of the editor. |
| pub fn set_run_editor(&mut self, run_editor: impl FnOnce(&Path) -> io::Result<bool> + 'a) { |
| self.run_editor = Box::new(run_editor); |
| } |
| |
| /// Add comment lines to the editor. Any newlines in the input will be |
| /// normalized to the current platform, and a comment character will be |
| /// added. |
| pub fn add_comments(&mut self, text: &str) -> io::Result<()> { |
| let text = text.trim(); |
| if text.is_empty() { |
| write!(self.tempfile, "{}{}", self.comment_char, LINE_ENDING)?; |
| } |
| for line in text.lines() { |
| if line.is_empty() { |
| write!(self.tempfile, "{}{}", self.comment_char, LINE_ENDING)?; |
| } else { |
| write!( |
| self.tempfile, |
| "{} {}{}", |
| self.comment_char, line, LINE_ENDING |
| )?; |
| } |
| } |
| Ok(()) |
| } |
| |
| /// Add non-comment lines to the editor. These lines must not start with |
| /// comment_character. |
| pub fn add_text(&mut self, text: &str) -> io::Result<()> { |
| let text = text.trim(); |
| if text.is_empty() { |
| write!(self.tempfile, "{LINE_ENDING}")?; |
| } |
| for line in text.lines() { |
| assert!( |
| !line.starts_with(self.comment_char), |
| "non-comment lines cannot start with a '{}' comment character", |
| self.comment_char |
| ); |
| write!(self.tempfile, "{line}{LINE_ENDING}")?; |
| } |
| Ok(()) |
| } |
| |
| /// Run the editor, collecting and filtering the resulting file, and |
| /// returning it as a string. |
| pub fn edit(self) -> Result<String, EditError> { |
| // Close our handle on the file to allow other programs like the editor |
| // to modify it on Windows. |
| let path = self.tempfile.into_temp_path(); |
| (self.run_editor)(&path).map_err(EditError::CouldntLaunch)?; |
| |
| // Read in the result, filtering lines, and restoring unix line endings. |
| // This is roughly based on git's logic for cleaning up commit message |
| // files. |
| let mut lines: Vec<String> = Vec::new(); |
| for line in BufReader::new(File::open(&path).map_err(EditError::CouldntOpen)?).lines() { |
| let line = line.map_err(EditError::CouldntRead)?; |
| // Ignore lines starting with a comment character. |
| if line.starts_with(self.comment_char) { |
| continue; |
| } |
| // Trim any trailing whitespace from each line, but leave leading |
| // whitespace untouched to avoid breaking formatted text. |
| let line = line.trim_end(); |
| // Don't record 2 consecutive empty lines or empty lines at the |
| // start of the file. |
| if line.is_empty() && lines.last().map_or(true, |l| l.is_empty()) { |
| continue; |
| } |
| lines.push(line.to_owned()); |
| } |
| |
| // Ensure there's a trailing newline for non-empty files. |
| match lines.last() { |
| None => return Ok(String::new()), |
| Some(line) if !line.is_empty() => lines.push(String::new()), |
| _ => {} |
| } |
| |
| Ok(lines.join("\n")) |
| } |
| } |
| |
| /// Read the git configuration to determine the value for GIT_EDITOR. |
| fn git_pager() -> Option<String> { |
| let output = Command::new("git") |
| .arg("var") |
| .arg("GIT_PAGER") |
| .output() |
| .ok()?; |
| if !output.status.success() { |
| return None; |
| } |
| let pager = str::from_utf8(&output.stdout).ok()?.trim().to_owned(); |
| if pager == "cat" { |
| return None; |
| } |
| Some(pager) |
| } |
| |
| /// Get a Command which can be used to invoke the user's EDITOR to edit a |
| /// document when passed an argument. This will try to use the user's configured |
| /// GIT_EDITOR when possible. |
| fn pager_command(out: &dyn Out) -> Option<Command> { |
| if !out.is_term() { |
| return None; |
| } |
| |
| // Try to use the user's configured pager if we're able to locate their git |
| // install. If this fails, we'll not use a pager. Don't bother warning in |
| // that case. |
| let git_sh = git_sh_path()?; |
| let git_pager = git_pager()?; |
| |
| let mut cmd = Command::new(git_sh); |
| cmd.arg("-c") |
| .arg(format!("{git_pager} \"$@\"")) |
| .arg(git_pager); |
| // These environment variables are hard-coded into `git` at build |
| // time, and are required to support colors in `less`. |
| cmd.env("LESS", "FRX").env("LV", "-c"); |
| Some(cmd) |
| } |
| |
| pub struct Pager<'a> { |
| out: &'a dyn Out, |
| child: Option<Child>, |
| use_color: bool, |
| } |
| |
| impl<'a> Pager<'a> { |
| /// Create a new pager for the given output stream, or a dummy pager if no pager is available. |
| pub fn new(out: &'a dyn Out) -> Result<Self, io::Error> { |
| let child = if let Some(mut cmd) = pager_command(out) { |
| Some(cmd.stdin(Stdio::piped()).spawn()?) |
| } else { |
| None |
| }; |
| let use_color = child.is_some() |
| && out.is_term() |
| && console::colors_enabled() |
| && git_config_bool("pager.color") |
| .or_else(|| git_config_bool("color.pager")) |
| .unwrap_or(true); |
| Ok(Pager { |
| out, |
| child, |
| use_color, |
| }) |
| } |
| |
| /// Should attempts to write to this pager include ANSI color codes? |
| pub fn use_color(&self) -> bool { |
| self.use_color |
| } |
| |
| /// Wait for the pager to stop running, so it's OK to start writing to |
| /// output again. |
| pub fn wait(self) -> Result<(), io::Error> { |
| if let Some(mut child) = self.child { |
| child.wait()?; |
| } |
| Ok(()) |
| } |
| } |
| |
| impl<'a> io::Write for Pager<'a> { |
| fn write(&mut self, buf: &[u8]) -> io::Result<usize> { |
| match &mut self.child { |
| Some(child) => child.stdin.as_mut().unwrap().write(buf), |
| None => self.out.write(buf), |
| } |
| } |
| |
| fn flush(&mut self) -> io::Result<()> { |
| match &mut self.child { |
| Some(child) => child.stdin.as_mut().unwrap().flush(), |
| None => Ok(()), |
| } |
| } |
| } |