use std::{borrow::Cow, str::FromStr};
use anyhow::{Context as _, Result};
#[derive(Clone, Debug)]
pub struct HeaderRange {
pub start: usize,
pub range: usize,
}
#[derive(Clone, Debug)]
pub struct HunkHeader {
pub source: HeaderRange,
#[allow(dead_code)]
pub dest: HeaderRange,
pub fixed_source: Option<HeaderRange>,
pub fixed_dest: Option<HeaderRange>,
}
#[derive(Clone, Debug, strum_macros::EnumIs)]
pub enum HunkLine {
Context(String),
Added(String),
Removed(String),
}
impl HunkLine {
pub fn content(&self) -> &str {
match self {
HunkLine::Removed(s) | HunkLine::Context(s) | HunkLine::Added(s) => s,
}
}
pub fn as_patch_line(&self) -> Cow<str> {
match self {
HunkLine::Context(s) => Cow::Owned(format!(" {s}")),
HunkLine::Added(s) => Cow::Owned(format!("+{s}")),
HunkLine::Removed(s) => Cow::Owned(format!("-{s}")),
}
}
}
#[derive(Clone, Debug)]
pub struct Hunk {
pub header: HunkHeader,
pub lines: Vec<HunkLine>,
pub body: String,
}
impl<'a> From<&'a Hunk> for Cow<'a, Hunk> {
fn from(val: &'a Hunk) -> Self {
Cow::Borrowed(val)
}
}
impl From<Hunk> for Cow<'_, Hunk> {
fn from(val: Hunk) -> Self {
Cow::Owned(val)
}
}
impl Hunk {
fn matchable_lines(&self) -> impl Iterator<Item = &HunkLine> {
self.lines
.iter()
.filter(|l| l.is_removed() || l.is_context())
}
pub fn insert_line_at(&mut self, line: HunkLine, index: usize) {
self.lines.insert(self.real_index(index), line);
}
pub fn real_index(&self, index: usize) -> usize {
self.lines
.iter()
.enumerate()
.filter(|(_, l)| l.is_removed() || l.is_context())
.nth(index)
.map_or_else(|| self.lines.len(), |(i, _)| i)
}
pub fn matches(&self, line: &str, index: usize, log: bool) -> bool {
let expected = self
.matchable_lines()
.skip(index)
.map(HunkLine::content)
.next();
let outcome = expected == Some(line);
if log {
if outcome {
tracing::trace!(line, expected, "Matched line");
} else {
tracing::trace!(line, expected, "Did not match line");
}
}
outcome
}
pub fn render_updated(&self) -> Result<String> {
let header_context = self
.body
.lines()
.next()
.unwrap_or_default()
.rsplit("@@")
.next()
.unwrap_or_default();
let source = self
.header
.fixed_source
.as_ref()
.context("Expected updated source")?;
let dest = self
.header
.fixed_dest
.as_ref()
.context("Expected updated dest")?;
let mut updated = format!(
"@@ -{},{} +{},{} @@{header_context}\n",
source.start + 1,
source.range,
dest.start + 1,
dest.range
);
for line in &self.lines {
updated.push_str(&line.as_patch_line());
updated.push('\n');
}
Ok(updated.to_string())
}
}
#[derive(Clone, Debug)]
pub struct Candidate<'a> {
start: usize,
current_line: usize,
hunk: Cow<'a, Hunk>,
}
impl<'a> Candidate<'a> {
pub fn new(line: usize, hunk: impl Into<Cow<'a, Hunk>>) -> Self {
Self {
start: line,
current_line: 0,
hunk: hunk.into(),
}
}
#[allow(clippy::cast_possible_wrap)]
pub fn offset(&self) -> isize {
self.hunk.lines.iter().filter(|l| l.is_added()).count() as isize
- self.hunk.lines.iter().filter(|l| l.is_removed()).count() as isize
}
pub fn next_line_matches(&self, line: &str) -> bool {
self.hunk.matches(line, self.current_line, true)
}
pub fn is_complete(&self) -> bool {
self.current_line == self.hunk.matchable_lines().count()
}
pub fn updated_source_header(&self) -> HeaderRange {
let source_lines = self
.hunk
.lines
.iter()
.filter(|l| l.is_removed() || l.is_context())
.count();
let source_start = self.start;
HeaderRange {
start: source_start,
range: source_lines,
}
}
pub fn updated_dest_header(&self, offset: isize) -> HeaderRange {
let dest_lines = self
.hunk
.lines
.iter()
.filter(|l| l.is_added() || l.is_context())
.count();
let dest_start = self.start.saturating_add_signed(offset);
HeaderRange {
start: dest_start,
range: dest_lines,
}
}
}
impl FromStr for Hunk {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let header: HunkHeader = s.parse()?;
let lines = s
.lines()
.skip(1)
.map(FromStr::from_str)
.collect::<Result<Vec<HunkLine>>>()?;
Ok(Hunk {
header,
lines,
body: s.into(),
})
}
}
impl FromStr for HunkLine {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Some(line) = s.strip_prefix('+') {
Ok(HunkLine::Added(line.into()))
} else if let Some(line) = s.strip_prefix('-') {
Ok(HunkLine::Removed(line.into()))
} else {
let s = s.strip_prefix(' ').unwrap_or(s);
Ok(HunkLine::Context(s.into()))
}
}
}
impl FromStr for HunkHeader {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if !s.starts_with("@@") {
anyhow::bail!("Hunk header must start with @@");
}
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() < 4 {
anyhow::bail!("Invalid hunk header format");
}
let old_range = parts[1].split(',').collect::<Vec<&str>>();
let new_range = parts[2].split(',').collect::<Vec<&str>>();
if old_range.len() != 2 || new_range.len() != 2 {
anyhow::bail!("Invalid range format in hunk header");
}
let old_lines = HeaderRange {
start: old_range[0]
.replace('-', "")
.parse()
.context("Invalid old start line")?,
range: old_range[1].parse().context("Invalid old range")?,
};
let new_lines = HeaderRange {
start: new_range[0]
.replace('+', "")
.parse()
.context("Invalid new start line")?,
range: new_range[1].parse().context("Invalid new range")?,
};
Ok(HunkHeader {
source: old_lines,
dest: new_lines,
fixed_source: None,
fixed_dest: None,
})
}
}
pub fn parse_hunks(patch: &str) -> Result<Vec<Hunk>> {
let mut hunks = Vec::new();
let mut current_hunk_lines = Vec::new();
for line in patch.lines() {
if line.starts_with("@@") {
if !current_hunk_lines.is_empty() {
let hunk = Hunk::from_str(¤t_hunk_lines.join("\n"))?;
hunks.push(hunk);
}
current_hunk_lines = vec![line];
} else if !current_hunk_lines.is_empty() {
current_hunk_lines.push(line);
}
}
if !current_hunk_lines.is_empty() {
let hunk = Hunk::from_str(¤t_hunk_lines.join("\n"))?;
hunks.push(hunk);
}
Ok(hunks)
}
pub fn find_candidates<'a>(content: &str, hunks: &'a [Hunk]) -> Vec<Candidate<'a>> {
let mut candidates = Vec::new();
for (line_n, line) in content.lines().enumerate() {
if let Some(hunk) = hunks.iter().find(|h| h.matches(line, 0, false)) {
tracing::trace!(line, "Found hunk match; creating new candidate");
candidates.push(Candidate::new(line_n, hunk));
}
let mut new_candidates = Vec::new();
candidates.retain_mut(|c| {
if c.is_complete() {
true
} else if c.next_line_matches(line) {
tracing::trace!(line, "Candidate matched line");
c.current_line += 1;
true
} else if line.trim().is_empty() {
tracing::trace!(line, "Current line is empty; keeping candidate around");
let mut new_hunk: Hunk = c.hunk.clone().into_owned();
new_hunk.insert_line_at(HunkLine::Context(line.into()), c.current_line);
let mut new_candidate = Candidate::new(c.start, new_hunk);
new_candidate.current_line = c.current_line + 1;
new_candidates.push(new_candidate);
false
} else if c
.hunk
.lines.iter()
.skip(c.hunk.real_index(c.current_line + 1))
.all(HunkLine::is_context)
{
tracing::trace!(line, "Mismatch; remaining is context only, adding finished candidate without the remaining lines");
let real_index = c.hunk.real_index(c.current_line);
let mut new_hunk = c.hunk.clone().into_owned();
new_hunk.lines = new_hunk
.lines
.iter()
.take(real_index)
.cloned()
.collect();
let mut new_candidate = Candidate::new(c.start, new_hunk);
new_candidate.current_line = c.current_line;
new_candidates.push(new_candidate);
false
} else {
tracing::trace!(line, "Removing candidate");
false
}
});
candidates.append(&mut new_candidates);
}
candidates
}
pub fn rebuild_hunks(candidates: &[Candidate<'_>]) -> Vec<Hunk> {
let mut current_offset: isize = 0;
let mut hunks: Vec<Hunk> = Vec::new();
for candidate in candidates {
let source_header = candidate.updated_source_header();
let dest_header = candidate.updated_dest_header(current_offset);
current_offset += candidate.offset();
let mut hunk = candidate.hunk.clone().into_owned();
hunk.header.fixed_source = Some(source_header);
hunk.header.fixed_dest = Some(dest_header);
if let Some(existing) = hunks.iter_mut().find(|h| *h.body == hunk.body) {
let (Some(existing_source), Some(new_source)) =
(&existing.header.fixed_source, &hunk.header.fixed_source)
else {
tracing::warn!("Potential bad duplicate when rebuilding patch; could be a bug, please check the edit");
continue;
};
#[allow(clippy::cast_possible_wrap)]
if ((existing_source.start as isize)
.saturating_sub_unsigned(existing.header.source.start))
.abs()
< ((new_source.start as isize).saturating_sub_unsigned(hunk.header.source.start))
.abs()
{
continue;
}
*existing = hunk;
} else {
hunks.push(hunk);
}
}
hunks
}
pub fn rebuild_patch(original: &str, hunks: &[Hunk]) -> Result<String> {
let mut new_patch = original.lines().take(2).collect::<Vec<_>>().join("\n");
new_patch.push('\n');
debug_assert!(
!new_patch.is_empty(),
"Original file lines in patch tools are empty"
);
for hunk in hunks {
new_patch.push_str(&hunk.render_updated()?);
}
Ok(new_patch)
}