~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs-knotless

0
0
main
122 lines · 5.7 KB Raw History
//! Applies a unified diff of one file, as `git apply` would: each hunk's context must match
//! exactly, at its line or the nearest line after the previous hunk where it does.

/// What a patch leaves of `old`: the new text, or None when it deletes the file (`+++ /dev/null`).
pub fn apply(old: &str, patch: &str) -> Result<Option<String>, String> {
    let lines: Vec<&str> = old.split_inclusive('\n').collect();
    let mut out: Vec<String> = Vec::new();
    let mut pos = 0;
    let mut offset: isize = 0;
    let mut deletes = false;
    let mut hunks = 0;
    let mut rows = patch.split_inclusive('\n').peekable();
    while let Some(row) = rows.next() {
        if row.starts_with("+++ /dev/null") {
            deletes = true;
        }
        let Some(header) = row.strip_prefix("@@ ") else { continue };
        let (old_start, old_count, new_count) = parse_header(header)?;
        hunks += 1;
        let (mut from, mut to): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
        let (mut left_old, mut left_new) = (old_count, new_count);
        while left_old > 0 || left_new > 0 {
            let row = rows.next().ok_or("a hunk ends early")?;
            let text = row.get(1..).unwrap_or("").to_string();
            match row.as_bytes().first() {
                Some(b' ') | Some(b'\n') | Some(b'\r') => {
                    let text = if row.starts_with(' ') { text } else { row.to_string() };
                    from.push(text.clone());
                    to.push(text);
                    left_old = left_old.checked_sub(1).ok_or("a hunk is longer than its header says")?;
                    left_new = left_new.checked_sub(1).ok_or("a hunk is longer than its header says")?;
                }
                Some(b'-') => {
                    from.push(text);
                    left_old = left_old.checked_sub(1).ok_or("a hunk is longer than its header says")?;
                }
                Some(b'+') => {
                    to.push(text);
                    left_new = left_new.checked_sub(1).ok_or("a hunk is longer than its header says")?;
                }
                Some(b'\\') => continue,
                _ => return Err(format!("not a hunk line: {row:?}")),
            }
            // "\ No newline at end of file" takes the newline off the line before it.
            if rows.peek().is_some_and(|r| r.starts_with('\\')) {
                let side = row.as_bytes()[0];
                if side != b'+' {
                    strip(&mut from);
                }
                if side != b'-' {
                    strip(&mut to);
                }
            }
        }
        if rows.peek().is_some_and(|r| r.starts_with('\\')) {
            rows.next();
        }
        // Where the hunk says it goes, then outward from there, never before the previous hunk.
        let want = (if old_count == 0 { old_start } else { old_start.saturating_sub(1) } as isize + offset).max(pos as isize) as usize;
        let fits = |at: usize| at + from.len() <= lines.len() && lines[at..at + from.len()].iter().zip(&from).all(|(a, b)| *a == b);
        let at = (0..=lines.len())
            .flat_map(|d| [want.checked_add(d), want.checked_sub(d).filter(|_| d > 0)])
            .flatten()
            .filter(|&at| at >= pos && at <= lines.len())
            .find(|&at| fits(at))
            .ok_or_else(|| format!("hunk {hunks} (@@ {}) does not apply", header.trim_end()))?;
        out.extend(lines[pos..at].iter().map(|s| s.to_string()));
        out.extend(to);
        offset = at as isize - old_start.saturating_sub(1) as isize;
        pos = at + from.len();
    }
    if hunks == 0 && !deletes {
        return Err("the patch has no hunks".into());
    }
    out.extend(lines[pos..].iter().map(|s| s.to_string()));
    Ok((!deletes).then(|| out.concat()))
}

fn strip(side: &mut [String]) {
    if let Some(last) = side.last_mut() {
        if last.ends_with('\n') {
            last.pop();
            if last.ends_with('\r') {
                last.pop();
            }
        }
    }
}

// "-a[,b] +c[,d] @@ ..." -> (a, b, d)
fn parse_header(header: &str) -> Result<(usize, usize, usize), String> {
    let bad = || format!("not a hunk header: @@ {}", header.trim_end());
    let mut parts = header.split_whitespace();
    let range = |part: Option<&str>, sign: char| -> Result<(usize, usize), String> {
        let part = part.and_then(|p| p.strip_prefix(sign)).ok_or_else(bad)?;
        let (start, count) = part.split_once(',').unwrap_or((part, "1"));
        Ok((start.parse().map_err(|_| bad())?, count.parse().map_err(|_| bad())?))
    };
    let (old_start, old_count) = range(parts.next(), '-')?;
    let (_, new_count) = range(parts.next(), '+')?;
    Ok((old_start, old_count, new_count))
}

#[cfg(test)]
mod tests {
    use super::apply;

    #[test]
    fn hunks_apply_with_offsets_and_newline_markers() {
        let old = "a\nb\nc\nd\ne\nf\n";
        let patch = "--- a/x\n+++ b/x\n@@ -2,2 +2,2 @@\n b\n-c\n+C\n@@ -5,2 +5,3 @@\n e\n-f\n+F\n+g\n\\ No newline at end of file\n";
        assert_eq!(apply(old, patch).unwrap().unwrap(), "a\nb\nC\nd\ne\nF\ng");
        // The same hunk two lines off still finds its context.
        assert_eq!(apply("x\ny\na\nb\nc\n", "@@ -2,2 +2,2 @@\n b\n-c\n+C\n").unwrap().unwrap(), "x\ny\na\nb\nC\n");
        // A new file, a deleted one, and context that is not there.
        assert_eq!(apply("", "--- /dev/null\n+++ b/n\n@@ -0,0 +1,2 @@\n+1\n+2\n").unwrap().unwrap(), "1\n2\n");
        assert_eq!(apply("1\n", "--- a/n\n+++ /dev/null\n@@ -1 +0,0 @@\n-1\n").unwrap(), None);
        assert!(apply("a\n", "@@ -1 +1 @@\n-z\n+y\n").unwrap_err().contains("does not apply"));
        // Adding a final newline.
        assert_eq!(apply("a", "@@ -1 +1 @@\n-a\n\\ No newline at end of file\n+a\n").unwrap().unwrap(), "a\n");
    }
}