Skip to main content

aft/hashline/apply/
repair.rs

1//! Repair layers applied to lowered replacement groups before materialization.
2//!
3//! Layers owned by this module:
4//! - **boundary-echo**: drop payload lines that exactly restate surviving lines
5//!   just outside the replaced span.
6//! - **indent**: restore a uniformly omitted base indent when unchanged rows
7//!   prove the shift.
8//! - **replacement-coalescing**: handled in [`super::edits::coalesce_replacement_edits`]
9//!   before these layers run.
10//!
11//! Exact verbatim remap recovery is intentionally out of scope here; that path
12//! belongs to the recovery planner and never runs as a silent repair.
13
14use super::edits::{find_replacement_group, InsertMode, InsertPlace, LineEdit, ReplacementGroup};
15
16/// Outcome of running every local repair layer on a lowered edit list.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct RepairOutcome {
19    pub edits: Vec<LineEdit>,
20    pub warnings: Vec<String>,
21    /// Which named repair layers actually rewrote the edit list.
22    pub layers_applied: Vec<&'static str>,
23}
24
25/// Run indent repair then boundary-echo repair. Coalescing is expected to have
26/// already normalized contiguous replacements into groups.
27pub fn apply_repair_layers(edits: &[LineEdit], file_lines: &[String]) -> RepairOutcome {
28    let mut working = edits.to_vec();
29    let mut warnings = Vec::new();
30    let mut layers_applied = Vec::new();
31
32    let indent = repair_replacement_indentation(&mut working, file_lines);
33    if !indent.is_empty() {
34        layers_applied.push("indent");
35        warnings.extend(indent);
36    }
37
38    let echo = repair_boundary_echoes(&mut working, file_lines);
39    if !echo.is_empty() {
40        layers_applied.push("boundary-echo");
41        warnings.extend(echo);
42    }
43
44    RepairOutcome {
45        edits: working,
46        warnings,
47        layers_applied,
48    }
49}
50
51/// Restore a uniformly omitted base indent only when the payload would escape
52/// a surviving `{` opener immediately above the replacement and matching
53/// unchanged rows prove the uniform shift.
54fn repair_replacement_indentation(edits: &mut [LineEdit], file_lines: &[String]) -> Vec<String> {
55    let mut warnings = Vec::new();
56    let mut start = 0;
57    while start < edits.len() {
58        let Some(group) = find_replacement_group(edits, start) else {
59            start += 1;
60            continue;
61        };
62        let last = *group.delete_indices.last().unwrap_or(&start);
63        start = last + 1;
64        if group.payload.len() != group.delete_indices.len() {
65            continue;
66        }
67        let preceding = file_lines
68            .get(group.start_line.saturating_sub(2))
69            .map(String::as_str)
70            .unwrap_or("");
71        let source_first = file_lines
72            .get(group.start_line.saturating_sub(1))
73            .map(String::as_str)
74            .unwrap_or("");
75        let payload_first = group.payload.first().map(String::as_str).unwrap_or("");
76        if !preceding.trim_end().ends_with('{')
77            || !is_indent_deeper(leading_indent(source_first), leading_indent(preceding))
78            || is_indent_deeper(leading_indent(payload_first), leading_indent(preceding))
79        {
80            continue;
81        }
82
83        let mut shift: Option<String> = None;
84        let mut matches = 0usize;
85        let mut consistent = true;
86        for offset in 0..group.payload.len() {
87            let source = file_lines
88                .get(group.start_line - 1 + offset)
89                .map(String::as_str)
90                .unwrap_or("");
91            let payload = group.payload[offset].as_str();
92            if source.trim().is_empty() || source.trim_start() != payload.trim_start() {
93                continue;
94            }
95            let source_indent = leading_indent(source);
96            let payload_indent = leading_indent(payload);
97            if !source_indent.ends_with(payload_indent) {
98                consistent = false;
99                break;
100            }
101            let candidate = source_indent[..source_indent.len() - payload_indent.len()].to_string();
102            match &shift {
103                None => shift = Some(candidate),
104                Some(existing) if existing != &candidate => {
105                    consistent = false;
106                    break;
107                }
108                Some(_) => {}
109            }
110            matches += 1;
111        }
112        if !consistent || shift.is_none() || matches < 2 || matches * 2 <= group.payload.len() {
113            continue;
114        }
115        let shift = shift.unwrap();
116        for index in &group.insert_indices {
117            if let LineEdit::Insert { text, .. } = &mut edits[*index] {
118                if !text.trim().is_empty() {
119                    *text = format!("{shift}{text}");
120                }
121            }
122        }
123        warnings.push(format!(
124            "Auto-indented a replacement body at line {}: restored a uniformly omitted base indent.",
125            group.start_line
126        ));
127    }
128    warnings
129}
130
131/// Drop payload lines that exactly restate surviving lines outside the range.
132fn repair_boundary_echoes(edits: &mut Vec<LineEdit>, file_lines: &[String]) -> Vec<String> {
133    let mut warnings = Vec::new();
134    let mut rebuilt = Vec::with_capacity(edits.len());
135    let mut i = 0;
136    while i < edits.len() {
137        let Some(group) = find_replacement_group(edits, i) else {
138            rebuilt.push(edits[i].clone());
139            i += 1;
140            continue;
141        };
142        let last = *group.delete_indices.last().unwrap();
143        i = last + 1;
144
145        if let Some(echo) = find_boundary_echo(&group, file_lines) {
146            let inserts: Vec<LineEdit> = group
147                .insert_indices
148                .iter()
149                .skip(echo.leading)
150                .take(group.insert_indices.len() - echo.leading - echo.trailing)
151                .map(|idx| edits[*idx].clone())
152                .collect();
153            let deletes: Vec<LineEdit> = group
154                .delete_indices
155                .iter()
156                .map(|idx| edits[*idx].clone())
157                .collect();
158            rebuilt.extend(inserts);
159            rebuilt.extend(deletes);
160            warnings.push(format!(
161                "Auto-repaired a replacement boundary echo at line {}: dropped {} leading and {} trailing payload line(s) already present outside the range.",
162                group.start_line, echo.leading, echo.trailing
163            ));
164            continue;
165        }
166
167        if let Some((side, count)) = find_one_sided_boundary_echo(&group, file_lines) {
168            let inserts: Vec<LineEdit> = match side {
169                EchoSide::Leading => group
170                    .insert_indices
171                    .iter()
172                    .skip(count)
173                    .map(|idx| edits[*idx].clone())
174                    .collect(),
175                EchoSide::Trailing => group
176                    .insert_indices
177                    .iter()
178                    .take(group.insert_indices.len() - count)
179                    .map(|idx| edits[*idx].clone())
180                    .collect(),
181            };
182            let deletes: Vec<LineEdit> = group
183                .delete_indices
184                .iter()
185                .map(|idx| edits[*idx].clone())
186                .collect();
187            rebuilt.extend(inserts);
188            rebuilt.extend(deletes);
189            warnings.push(format!(
190                "Auto-repaired a replacement boundary echo at line {}: dropped {} {} payload line(s) identical to the surviving line(s) just outside the range.",
191                group.start_line,
192                count,
193                match side {
194                    EchoSide::Leading => "leading",
195                    EchoSide::Trailing => "trailing",
196                }
197            ));
198            continue;
199        }
200
201        for idx in group
202            .insert_indices
203            .iter()
204            .chain(group.delete_indices.iter())
205        {
206            rebuilt.push(edits[*idx].clone());
207        }
208    }
209    *edits = rebuilt;
210    warnings
211}
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214struct BoundaryEcho {
215    leading: usize,
216    trailing: usize,
217}
218
219#[derive(Clone, Copy, Debug, Eq, PartialEq)]
220enum EchoSide {
221    Leading,
222    Trailing,
223}
224
225fn find_boundary_echo(group: &ReplacementGroup, file_lines: &[String]) -> Option<BoundaryEcho> {
226    let leading = count_duplicate_leading(group, file_lines);
227    if leading == 0 {
228        return None;
229    }
230    let trailing = count_duplicate_trailing(group, file_lines);
231    if trailing == 0 {
232        return None;
233    }
234    if leading + trailing >= group.payload.len() {
235        return None;
236    }
237    Some(BoundaryEcho { leading, trailing })
238}
239
240fn find_one_sided_boundary_echo(
241    group: &ReplacementGroup,
242    file_lines: &[String],
243) -> Option<(EchoSide, /* count */ usize)> {
244    let leading = count_duplicate_leading(group, file_lines);
245    let trailing = count_duplicate_trailing(group, file_lines);
246    if (leading > 0) == (trailing > 0) {
247        return None;
248    }
249    let (side, count) = if leading > 0 {
250        (EchoSide::Leading, leading)
251    } else {
252        (EchoSide::Trailing, trailing)
253    };
254    if count >= group.payload.len() {
255        return None;
256    }
257    // Single-line ranges only drop trailing structural closers.
258    if group.delete_indices.len() <= 1 {
259        if side != EchoSide::Trailing {
260            return None;
261        }
262        let echo_lines = &group.payload[group.payload.len() - count..];
263        if !echo_lines.iter().all(|line| is_structural_closer(line)) {
264            return None;
265        }
266    }
267    Some((side, count))
268}
269
270fn count_duplicate_leading(group: &ReplacementGroup, file_lines: &[String]) -> usize {
271    let max = group.payload.len().min(group.start_line.saturating_sub(1));
272    for count in (1..=max).rev() {
273        let mut matches = true;
274        let mut has_content = false;
275        for offset in 0..count {
276            let line = &group.payload[offset];
277            let file_idx = group.start_line - 1 - count + offset;
278            if file_lines.get(file_idx).map(String::as_str) != Some(line.as_str()) {
279                matches = false;
280                break;
281            }
282            has_content |= has_non_whitespace(line);
283        }
284        if matches && has_content {
285            return count;
286        }
287    }
288    0
289}
290
291fn count_duplicate_trailing(group: &ReplacementGroup, file_lines: &[String]) -> usize {
292    let max = group
293        .payload
294        .len()
295        .min(file_lines.len().saturating_sub(group.end_line));
296    for count in (1..=max).rev() {
297        let mut matches = true;
298        let mut has_content = false;
299        for offset in 0..count {
300            let line = &group.payload[group.payload.len() - count + offset];
301            let file_idx = group.end_line + offset;
302            if file_lines.get(file_idx).map(String::as_str) != Some(line.as_str()) {
303                matches = false;
304                break;
305            }
306            has_content |= has_non_whitespace(line);
307        }
308        if matches && has_content {
309            return count;
310        }
311    }
312    0
313}
314
315fn leading_indent(line: &str) -> &str {
316    let end = line
317        .bytes()
318        .position(|b| b != b' ' && b != b'\t')
319        .unwrap_or(line.len());
320    &line[..end]
321}
322
323fn is_indent_deeper(deeper: &str, shallower: &str) -> bool {
324    deeper.len() > shallower.len() && deeper.starts_with(shallower)
325}
326
327fn has_non_whitespace(text: &str) -> bool {
328    text.bytes()
329        .any(|b| !matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
330}
331
332fn is_structural_closer(line: &str) -> bool {
333    let trimmed = line.trim();
334    if trimmed.is_empty() {
335        return false;
336    }
337    // Pure closer lines: `}`, `);`, `});`, `/>`, `</tag>`, `]`, etc.
338    let bytes = trimmed.as_bytes();
339    let first = bytes[0];
340    matches!(first, b'}' | b')' | b']' | b'/')
341        || trimmed.starts_with("</")
342        || trimmed
343            .chars()
344            .all(|c| matches!(c, '}' | ')' | ']' | ';' | ',' | '/' | '>'))
345}
346
347/// Build a synthetic replacement group for unit tests and negative controls.
348pub fn replacement_group_from_payload(
349    start_line: usize,
350    end_line: usize,
351    payload: Vec<String>,
352) -> (Vec<LineEdit>, ReplacementGroup) {
353    let mut edits = Vec::new();
354    for text in &payload {
355        edits.push(LineEdit::Insert {
356            anchor: start_line,
357            place: InsertPlace::Before,
358            text: text.clone(),
359            mode: InsertMode::Replacement,
360            op_index: 0,
361        });
362    }
363    for line in start_line..=end_line {
364        edits.push(LineEdit::Delete { line, op_index: 0 });
365    }
366    let group = find_replacement_group(&edits, 0).expect("constructed group");
367    (edits, group)
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::hashline::apply::edits::materialize_edits;
374
375    #[test]
376    fn boundary_echo_drops_restated_neighbors() {
377        let file = vec![
378            "keep-above".into(),
379            "old-a".into(),
380            "old-b".into(),
381            "keep-below".into(),
382        ];
383        let (mut edits, _) = replacement_group_from_payload(
384            2,
385            3,
386            vec![
387                "keep-above".into(),
388                "new-a".into(),
389                "new-b".into(),
390                "keep-below".into(),
391            ],
392        );
393        let warnings = repair_boundary_echoes(&mut edits, &file);
394        assert!(!warnings.is_empty());
395        let result = materialize_edits(&file, &edits);
396        assert_eq!(
397            result,
398            vec![
399                "keep-above".to_string(),
400                "new-a".into(),
401                "new-b".into(),
402                "keep-below".into()
403            ]
404        );
405    }
406
407    #[test]
408    fn indent_repair_restores_uniform_base() {
409        let file = vec![
410            "    if (value > 90) {".into(),
411            "      result = error;".into(),
412            "    } else if (value > 70) {".into(),
413            "      result = plain;".into(),
414            "    } else {".into(),
415            "      result = warning;".into(),
416            "    }".into(),
417        ];
418        let (mut edits, _) = replacement_group_from_payload(
419            2,
420            6,
421            vec![
422                "  result = error;".into(),
423                "} else if (value > 70) {".into(),
424                "  result = warning;".into(),
425                "} else {".into(),
426                "  result = plain;".into(),
427            ],
428        );
429        let warnings = repair_replacement_indentation(&mut edits, &file);
430        assert!(!warnings.is_empty());
431        let result = materialize_edits(&file, &edits);
432        assert_eq!(result[1], "      result = error;");
433        assert!(result[2].starts_with("    }"));
434    }
435
436    #[test]
437    fn intentional_indent_only_edit_is_not_repaired() {
438        let file = vec!["    first();".into(), "    second();".into()];
439        let (mut edits, _) =
440            replacement_group_from_payload(1, 2, vec!["first();".into(), "second();".into()]);
441        let warnings = repair_replacement_indentation(&mut edits, &file);
442        assert!(warnings.is_empty());
443        assert_eq!(
444            materialize_edits(&file, &edits),
445            vec!["first();".to_string(), "second();".into()]
446        );
447    }
448}