Skip to main content

aft/hashline/apply/
region.rs

1//! Affected-region construction for hashline edit responses.
2//!
3//! After a successful in-memory apply, the engine records which output rows
4//! changed. The snapshot publisher then expands each range with its nearest
5//! surviving predecessor and successor so chained edits have stable context.
6
7use crate::hashline::snapshot::{AffectedRegion, LineRange};
8
9/// Describe how one lowered operation changed the output line map.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum RegionDelta {
12    /// Replacement or pure insertion that produced `count` output rows starting
13    /// at 1-based `start`.
14    OutputSpan { start: usize, count: usize },
15    /// Pure deletion whose nearest surviving neighbors should be retained.
16    /// `at` is the 1-based output line where the deletion landed (the first
17    /// surviving line after the hole, or one past the end).
18    Deletion { at: usize },
19}
20
21/// Build an affected region from ordered per-operation deltas.
22///
23/// Ranges are coalesced so overlapping or adjacent edits render once. Empty
24/// files and pure whole-file removals yield an empty region; the publisher
25/// still carries empty-file boundary evidence from the final snapshot.
26pub fn build_affected_region(deltas: impl IntoIterator<Item = RegionDelta>) -> AffectedRegion {
27    let mut ranges = Vec::new();
28    for delta in deltas {
29        match delta {
30            RegionDelta::OutputSpan { start, count } if count > 0 && start > 0 => {
31                ranges.push(LineRange::new(start, start + count - 1));
32            }
33            RegionDelta::Deletion { at } if at > 0 => {
34                // Pure deletion retains neighbors via the publisher's expansion
35                // of an empty-at-hole marker. Recording `at` as a zero-width
36                // seed is represented by a single-line range at the hole so
37                // predecessor/successor selection still runs.
38                ranges.push(LineRange::new(at, at));
39            }
40            _ => {}
41        }
42    }
43    AffectedRegion::new(ranges)
44}
45
46/// Compute output-line deltas for a single-file apply by comparing original and
47/// final logical lines. This is the deterministic fallback used when the
48/// operation list does not carry explicit landing metadata.
49pub fn affected_from_line_diff(before: &[String], after: &[String]) -> AffectedRegion {
50    if before.is_empty() && after.is_empty() {
51        return AffectedRegion::default();
52    }
53    if before.is_empty() {
54        return AffectedRegion::insertion(1, after.len());
55    }
56    if after.is_empty() {
57        // Deletion to empty file: zero rows; publisher keeps empty-file evidence.
58        return AffectedRegion::default();
59    }
60
61    // Longest common prefix/suffix, then the middle is the changed span.
62    let mut prefix = 0usize;
63    while prefix < before.len() && prefix < after.len() && before[prefix] == after[prefix] {
64        prefix += 1;
65    }
66    let mut suffix = 0usize;
67    while suffix < before.len().saturating_sub(prefix)
68        && suffix < after.len().saturating_sub(prefix)
69        && before[before.len() - 1 - suffix] == after[after.len() - 1 - suffix]
70    {
71        suffix += 1;
72    }
73    let after_mid = after.len().saturating_sub(prefix + suffix);
74    if after_mid == 0 {
75        // Pure deletion in the middle: seed at the first surviving line after
76        // the hole (or the last surviving line when the hole is at EOF).
77        let at = if prefix < after.len() {
78            prefix + 1
79        } else {
80            after.len().max(1)
81        };
82        return build_affected_region([RegionDelta::Deletion { at }]);
83    }
84    AffectedRegion::insertion(prefix + 1, after_mid)
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn adjacent_spans_coalesce() {
93        let region = build_affected_region([
94            RegionDelta::OutputSpan { start: 2, count: 2 },
95            RegionDelta::OutputSpan { start: 4, count: 1 },
96        ]);
97        assert_eq!(region.ranges, vec![LineRange::new(2, 4)]);
98    }
99
100    #[test]
101    fn pure_insertion_into_empty_file() {
102        let region = affected_from_line_diff(&[], &["a".into(), "b".into()]);
103        assert_eq!(region.ranges, vec![LineRange::new(1, 2)]);
104    }
105
106    #[test]
107    fn deletion_to_empty_has_no_rows() {
108        let region = affected_from_line_diff(&["a".into()], &[]);
109        assert!(region.is_empty());
110    }
111
112    #[test]
113    fn middle_replacement_marks_new_rows() {
114        let before = vec!["a".into(), "b".into(), "c".into()];
115        let after = vec!["a".into(), "B".into(), "C".into(), "c".into()];
116        let region = affected_from_line_diff(&before, &after);
117        assert_eq!(region.ranges, vec![LineRange::new(2, 3)]);
118    }
119}