1use super::edits::{find_replacement_group, InsertMode, InsertPlace, LineEdit, ReplacementGroup};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct RepairOutcome {
19 pub edits: Vec<LineEdit>,
20 pub warnings: Vec<String>,
21 pub layers_applied: Vec<&'static str>,
23}
24
25pub 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
51fn 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
131fn 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, 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 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 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
347pub 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}