Skip to main content

agd_core/write/
patch.rs

1//! Byte patches: `Patch` and its journaled form `StoredPatch`, `apply_patches`,
2//! the one patch between two texts, line helpers, and the unified diff a
3//! mutation shows.
4//!
5//! Patches must not overlap, run past the source or split a UTF-8 character
6//! (`invalid_patch`), and every byte outside them is copied exactly. A stored
7//! patch keeps the text around it so undo can find it again.
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12use similar::TextDiff;
13
14use crate::{AgdError, ByteSpan, ErrorCode};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct Patch {
18    pub span: ByteSpan,
19    pub replacement: String,
20}
21
22/// A journaled replacement: `start..end` is the span of `after` in the
23/// post-image; `context_before`/`context_after` are the post-image bytes
24/// around it (the rest of its lines plus one line each way), used to find the
25/// span again if the file changed elsewhere before undo.
26#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
27pub struct StoredPatch {
28    pub start: usize,
29    pub end: usize,
30    pub before: String,
31    pub after: String,
32    #[serde(default)]
33    pub context_before: Option<String>,
34    #[serde(default)]
35    pub context_after: Option<String>,
36}
37
38pub fn apply_patches(
39    source: &str,
40    mut patches: Vec<Patch>,
41) -> Result<(String, Vec<StoredPatch>), AgdError> {
42    patches.sort_by_key(|patch| (patch.span.start, patch.span.end));
43    let mut cursor = 0;
44    let mut post_cursor = 0;
45    let mut output = String::with_capacity(source.len());
46    let mut stored = Vec::with_capacity(patches.len());
47    for patch in patches {
48        if patch.span.start < cursor
49            || patch.span.end < patch.span.start
50            || patch.span.end > source.len()
51        {
52            return Err(AgdError::new(
53                ErrorCode::InvalidPatch,
54                format!(
55                    "patch {}..{} overlaps or exceeds the source",
56                    patch.span.start, patch.span.end
57                ),
58            ));
59        }
60        if !source.is_char_boundary(patch.span.start) || !source.is_char_boundary(patch.span.end) {
61            return Err(AgdError::new(
62                ErrorCode::InvalidPatch,
63                "patch is not on UTF-8 boundaries",
64            ));
65        }
66        output.push_str(&source[cursor..patch.span.start]);
67        post_cursor += patch.span.start - cursor;
68        let post_start = post_cursor;
69        let before = source[patch.span.start..patch.span.end].to_string();
70        output.push_str(&patch.replacement);
71        post_cursor += patch.replacement.len();
72        stored.push(StoredPatch {
73            start: post_start,
74            end: post_cursor,
75            before,
76            after: patch.replacement,
77            context_before: None,
78            context_after: None,
79        });
80        cursor = patch.span.end;
81    }
82    output.push_str(&source[cursor..]);
83    for patch in &mut stored {
84        let context_start = line_start(&output, line_start(&output, patch.start).saturating_sub(1));
85        let context_end = line_end(&output, line_end(&output, patch.end) + 1);
86        patch.context_before = Some(output[context_start..patch.start].to_string());
87        patch.context_after = Some(output[patch.end..context_end].to_string());
88    }
89    Ok((output, stored))
90}
91
92fn line_start(text: &str, offset: usize) -> usize {
93    text[..offset.min(text.len())]
94        .rfind('\n')
95        .map_or(0, |index| index + 1)
96}
97
98/// Offset just past the newline that ends the line containing `offset`.
99fn line_end(text: &str, offset: usize) -> usize {
100    if offset >= text.len() {
101        return text.len();
102    }
103    text[offset..]
104        .find('\n')
105        .map_or(text.len(), |index| offset + index + 1)
106}
107
108/// One patch turning `before` into `after`: the differing middle, widened
109/// to whole lines so the journal reads like the diff.
110pub(super) fn changed_lines_patch(before: &str, after: &str) -> Patch {
111    let mut prefix = before
112        .bytes()
113        .zip(after.bytes())
114        .take_while(|(left, right)| left == right)
115        .count();
116    prefix = line_start(before, prefix);
117    let mut suffix = before
118        .bytes()
119        .rev()
120        .zip(after.bytes().rev())
121        .take_while(|(left, right)| left == right)
122        .count()
123        .min(before.len() - prefix)
124        .min(after.len() - prefix);
125    let tail = line_end_at_or_after(before, before.len() - suffix);
126    suffix = before.len() - tail;
127    while !before.is_char_boundary(before.len() - suffix)
128        || !after.is_char_boundary(after.len() - suffix)
129    {
130        suffix -= 1;
131    }
132    Patch {
133        span: ByteSpan {
134            start: prefix,
135            end: before.len() - suffix,
136        },
137        replacement: after[prefix..after.len() - suffix].to_string(),
138    }
139}
140
141/// `offset` if a line starts there, else the start of the next line.
142fn line_end_at_or_after(text: &str, offset: usize) -> usize {
143    if offset == 0 || offset >= text.len() || text.as_bytes()[offset - 1] == b'\n' {
144        return offset;
145    }
146    line_end(text, offset)
147}
148
149/// The line ending a text uses: CRLF when it has one, else LF.
150pub(crate) fn line_ending(source: &str) -> &'static str {
151    if source.contains("\r\n") {
152        "\r\n"
153    } else {
154        "\n"
155    }
156}
157
158pub(super) fn unified_diff(path: &Path, before: &str, after: &str) -> String {
159    TextDiff::from_lines(before, after)
160        .unified_diff()
161        .header(&path.display().to_string(), &path.display().to_string())
162        .to_string()
163}