use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::Write;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;
use std::sync::OnceLock;
use std::time::SystemTime;
use bstr::ByteSlice;
use grep_matcher::{Captures, Matcher};
use grep_regex::RegexMatcher;
pub fn ranges(sorted_list: &[u64], padding: u64) -> Vec<std::ops::RangeInclusive<u64>> {
let mut ranges = Vec::new();
let padding = std::num::Saturating(padding);
for x in sorted_list {
let x = std::num::Saturating(*x);
let Some(range) = ranges.last_mut() else {
let start = x - padding;
let end = x + padding;
ranges.push(start.0..=end.0);
continue;
};
if range.contains(&(x - padding).0) {
if *range.end() < (x + padding).0 {
let end = x + padding;
*range = *range.start()..=end.0;
}
continue;
}
let start = x - padding;
let end = x + padding;
ranges.push(start.0..=end.0);
}
ranges
}
pub fn replace_file<T>(
path: impl AsRef<Path>,
modified_at: Option<SystemTime>,
f: impl FnOnce(&File, &File) -> (bool, T),
) -> Result<T, ReplaceFileError> {
#[cfg(target_os = "linux")]
{
replace_file_linux(path, modified_at, true, f)
}
#[cfg(not(target_os = "linux"))]
{
replace_file_compat(path, modified_at, f)
}
}
#[cfg(target_os = "linux")]
fn replace_file_linux<T>(
path: impl AsRef<Path>,
modified_at: Option<SystemTime>,
allow_fallback: bool,
f: impl FnOnce(&File, &File) -> (bool, T),
) -> Result<T, ReplaceFileError> {
use std::ffi::CString;
use std::fs::OpenOptions;
use std::os::fd::AsRawFd;
use std::os::unix::fs::OpenOptionsExt;
let path = path.as_ref();
if !path.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a file").into());
}
let tmp_path = {
let mut ext = path.extension().unwrap_or(OsStr::new("")).to_os_string();
ext.push(OsStr::new(".asdf123.tmp"));
path.with_extension(ext)
};
let tmp_c_path = CString::new(tmp_path.as_os_str().as_bytes()).unwrap();
let mut parent_path = path.parent().unwrap();
if parent_path == Path::new("") {
parent_path = Path::new("./");
}
let new = match OpenOptions::new()
.write(true)
.truncate(true)
.custom_flags(libc::O_TMPFILE)
.open(parent_path)
{
Ok(x) => x,
Err(e) if allow_fallback && e.raw_os_error() == Some(libc::EOPNOTSUPP) => {
return replace_file_compat(path, modified_at, f);
}
Err(e) => return Err(e.into()),
};
let original = File::open(path)?;
#[allow(clippy::useless_conversion)]
let mask = u32::from(libc::S_IRWXU | libc::S_IRWXG | libc::S_IRWXO);
new.set_permissions(read_permissions(&original, mask)?)?;
let mut procfd_c_path = Vec::new();
procfd_c_path.extend(b"/proc/self/fd/");
procfd_c_path.extend(new.as_raw_fd().to_string().as_bytes());
let procfd_c_path = CString::new(procfd_c_path).unwrap();
let (do_replace_file, rv) = f(&original, &new);
if !do_replace_file {
return Ok(rv);
};
if let Some(modified_at) = modified_at {
let latest_modified = std::fs::metadata(path)?.modified()?;
if latest_modified != modified_at {
return Err(ReplaceFileError::ModifiedTimeChanged);
}
}
let linkat_rv = unsafe {
libc::linkat(
libc::AT_FDCWD,
procfd_c_path.as_ptr(),
libc::AT_FDCWD,
tmp_c_path.as_ptr(),
libc::AT_SYMLINK_FOLLOW,
)
};
if linkat_rv != 0 {
return Err(std::io::Error::last_os_error().into());
}
std::fs::rename(&tmp_path, path)?;
Ok(rv)
}
fn replace_file_compat<T>(
path: impl AsRef<Path>,
modified_at: Option<SystemTime>,
f: impl FnOnce(&File, &File) -> (bool, T),
) -> Result<T, ReplaceFileError> {
let path = path.as_ref();
if !path.is_file() {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a file").into());
}
#[allow(clippy::useless_conversion)]
let mask = u32::from(libc::S_IRWXU | libc::S_IRWXG | libc::S_IRWXO);
let original = File::open(path)?;
let original_permissions = read_permissions(&original, mask)?;
let mut prefix = OsString::new();
prefix.push(".");
prefix.push(path.file_name().unwrap());
prefix.push(".");
let mut new = tempfile::Builder::new();
let new = new
.prefix(&prefix)
.suffix(".tmp")
.permissions(original_permissions.clone())
.tempfile_in(path.parent().unwrap())?;
new.as_file().set_permissions(original_permissions)?;
let (do_replace_file, rv) = f(&original, new.as_file());
if !do_replace_file {
return Ok(rv);
};
if let Some(modified_at) = modified_at {
let latest_modified = std::fs::metadata(path)?.modified()?;
if latest_modified != modified_at {
return Err(ReplaceFileError::ModifiedTimeChanged);
}
}
new.persist(path).map_err(|e| e.error)?;
Ok(rv)
}
#[derive(Debug)]
pub enum ReplaceFileError {
Io(std::io::Error),
ModifiedTimeChanged,
}
impl From<std::io::Error> for ReplaceFileError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
impl std::fmt::Display for ReplaceFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "{e}"),
Self::ModifiedTimeChanged => {
write!(f, r#"the file's "modified" timestamp unexpectedly changed"#)
}
}
}
}
impl std::error::Error for ReplaceFileError {}
fn read_permissions(file: &File, mask: u32) -> std::io::Result<std::fs::Permissions> {
#[allow(clippy::useless_conversion)]
let file_type_mask = u32::from(libc::S_IFMT);
let mode = file.metadata()?.permissions().mode() & !file_type_mask;
let mode = mode & mask;
Ok(std::fs::Permissions::from_mode(mode))
}
pub fn editor_cmd() -> impl Iterator<Item = impl AsRef<OsStr>> + Clone {
static EDITOR_CMD: OnceLock<Vec<OsString>> = OnceLock::new();
fn split_whitespace(bytes: &[u8]) -> Vec<OsString> {
bytes
.fields()
.map(|x| OsString::from_vec(x.to_vec()))
.collect()
}
fn env_var(name: &str) -> Option<Vec<OsString>> {
if let Some(cmd) = std::env::var_os(name) {
let cmd = split_whitespace(cmd.as_bytes());
if !cmd.is_empty() {
return Some(cmd);
}
}
None
}
let cmd = EDITOR_CMD.get_or_init(|| {
if let Some(cmd) = env_var("VISUAL") {
return cmd;
}
if let Some(cmd) = env_var("EDITOR") {
return cmd;
}
if let Some(cmd) = env_var("GIT_EDITOR") {
return cmd;
}
if let Ok(output) = Command::new("git")
.arg("config")
.arg("--null")
.arg("core.editor")
.output()
{
if output.status.success() {
let mut output = output.stdout;
assert!(matches!(output.pop(), Some(0) | None));
if !output.is_empty() {
let cmd = split_whitespace(&output);
if !cmd.is_empty() {
return cmd;
}
}
}
}
[OsString::from_vec(b"vim".to_vec())].to_vec()
});
assert!(!cmd.is_empty());
cmd.iter()
}
pub fn replace_regex(
matcher: &RegexMatcher,
replacement: &[u8],
haystack: &[u8],
dest: &mut Vec<u8>,
) -> Result<(), <RegexMatcher as Matcher>::Error> {
let mut captures = matcher.new_captures().unwrap();
matcher.replace_with_captures(haystack, &mut captures, dest, |caps, dest| {
caps.interpolate(
|name| matcher.capture_index(name),
haystack,
replacement,
dest,
);
true
})
}
pub fn rewrite_patch_line_counts(bytes: &[u8]) -> std::borrow::Cow<[u8]> {
let result = (|| {
let mut lines = crate::parse::lines_with_pos(bytes);
let (header, header_start) = lines.nth(2)?;
let (range_1, range_2) = crate::parse::patch_block_header(header)?;
let mut content_start = None;
let mut line_counts = (0, 0);
for (line, pos) in lines {
if content_start.is_none() {
content_start = Some(pos);
}
match line.first() {
Some(b' ') | None => {
line_counts.0 += 1;
line_counts.1 += 1;
}
Some(b'-') => line_counts.0 += 1,
Some(b'+') => line_counts.1 += 1,
_ => return None,
}
}
if (range_1.1, range_2.1) == line_counts {
return None;
}
let content_start = content_start?;
let mut new_patch = Vec::new();
new_patch.extend_from_slice(&bytes[..header_start]);
writeln!(
&mut new_patch,
"@@ -{},{} +{},{} @@",
range_1.0, line_counts.0, range_2.0, line_counts.1,
)
.ok()?;
new_patch.extend_from_slice(&bytes[content_start..]);
Some(new_patch)
})();
match result {
Some(x) => std::borrow::Cow::Owned(x),
None => std::borrow::Cow::Borrowed(bytes),
}
}
pub fn rewrite_patch_line_start(bytes: &[u8], offset: i128, ansi: bool) -> Option<Vec<u8>> {
let mut lines = crate::parse::lines_with_pos(bytes);
let (mut header, header_start) = lines.nth(2)?;
let (_, content_start) = lines.next()?;
const ANSI_RESET: &[u8] = b"\x1b[0m";
const ANSI_HEADER_COLOR: &[u8] = b"\x1b[36m";
if ansi {
header = header.strip_prefix(ANSI_RESET)?;
header = header.strip_prefix(ANSI_HEADER_COLOR)?;
header = header.strip_suffix(ANSI_RESET)?;
}
let (mut pair_1, mut pair_2) = crate::parse::patch_block_header(header)?;
let (offset, positive_offset) = if offset >= 0 {
(u64::try_from(offset).ok()?, true)
} else {
(u64::try_from(-offset).ok()?, false)
};
if positive_offset {
pair_1.0 = pair_1.0.checked_add(offset)?;
pair_2.0 = pair_2.0.checked_add(offset)?;
} else {
pair_1.0 = pair_1.0.checked_sub(offset)?;
pair_2.0 = pair_2.0.checked_sub(offset)?;
}
let mut new_patch = Vec::new();
new_patch.extend_from_slice(&bytes[..header_start]);
if ansi {
new_patch.extend_from_slice(ANSI_RESET);
new_patch.extend_from_slice(ANSI_HEADER_COLOR);
}
write!(
&mut new_patch,
"@@ -{},{} +{},{} @@",
pair_1.0, pair_1.1, pair_2.0, pair_2.1,
)
.ok()?;
if ansi {
new_patch.extend_from_slice(ANSI_RESET);
}
writeln!(&mut new_patch).unwrap();
new_patch.extend_from_slice(&bytes[content_start..]);
Some(new_patch)
}
macro_rules! label {
($label:lifetime: $code:block) => {
$label: loop {
let _rv = {
$code
};
#[allow(unreachable_code)]
{
break $label _rv;
}
}
};
}
pub(crate) use label;
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_ranges() {
let list = [1, 2, 10, 12, 35, 38, 55, u64::MAX];
let padding = 5;
assert_eq!(
ranges(&list, padding),
[0..=17, 30..=43, 50..=60, u64::MAX - 5..=u64::MAX],
);
let list = [1, 2, 10, 12, 35, 38, 55, u64::MAX];
let padding = u64::MAX;
assert_eq!(ranges(&list, padding), [0..=u64::MAX]);
let list = [];
let padding = 5;
assert_eq!(ranges(&list, padding), []);
let list = [1, 2, 5, 7, 100];
let padding = 0;
assert_eq!(
ranges(&list, padding),
[1..=1, 2..=2, 5..=5, 7..=7, 100..=100]
);
let list = [1, 2, 5, 7, 100];
let padding = 1;
assert_eq!(ranges(&list, padding), [0..=3, 4..=8, 99..=101]);
}
macro_rules! replace_file_tester {
($f: ident) => {{
let mut file = tempfile::Builder::new().tempfile().unwrap();
file.write_all(b"hello world\n").unwrap();
$f(file.path(), None, |mut original, mut new| {
new.write_all(b"foo ").unwrap();
std::io::copy(&mut original, &mut new).unwrap();
(true, ())
})
.unwrap();
let file = file.into_temp_path();
assert_eq!(std::fs::read(&file).unwrap(), b"foo hello world\n");
let mut file = tempfile::Builder::new().tempfile().unwrap();
file.write_all(b"hello world\n").unwrap();
$f(file.path(), None, |mut original, mut new| {
new.write_all(b"foo ").unwrap();
std::io::copy(&mut original, &mut new).unwrap();
(false, ())
})
.unwrap();
assert_eq!(std::fs::read(file.path()).unwrap(), b"hello world\n");
let mut file = tempfile::Builder::new().tempfile().unwrap();
file.write_all(b"hello world\n").unwrap();
#[allow(clippy::useless_conversion)]
let target_permissions = u32::from(libc::S_IXUSR | libc::S_IRUSR);
let target_permissions = std::fs::Permissions::from_mode(target_permissions);
file.as_file()
.set_permissions(target_permissions.clone())
.unwrap();
assert_eq!(
read_permissions(&file.as_file(), u32::MAX).unwrap(),
target_permissions,
);
$f(file.path(), None, |mut original, mut new| {
new.write_all(b"foo ").unwrap();
std::io::copy(&mut original, &mut new).unwrap();
(true, ())
})
.unwrap();
let file = file.into_temp_path();
assert_eq!(std::fs::read(&file).unwrap(), b"foo hello world\n");
assert_eq!(
read_permissions(&File::open(&file).unwrap(), u32::MAX).unwrap(),
target_permissions,
);
}};
}
#[test]
fn test_replace_file() {
replace_file_tester!(replace_file);
}
#[test]
fn test_replace_file_compat() {
replace_file_tester!(replace_file_compat);
}
#[test]
#[ignore]
#[cfg(target_os = "linux")]
fn test_replace_file_linux() {
pub fn helper<T>(
path: impl AsRef<Path>,
modified_at: Option<SystemTime>,
f: impl FnOnce(&File, &File) -> (bool, T),
) -> Result<T, ReplaceFileError> {
replace_file_linux(path, modified_at, false, f)
}
replace_file_tester!(helper);
}
}