1use crate::hashline::scan::TerminatorKind;
7
8#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum LineEdit {
11 Insert {
12 anchor: usize,
14 place: InsertPlace,
15 text: String,
16 mode: InsertMode,
17 op_index: usize,
19 },
20 Delete {
21 line: usize,
22 op_index: usize,
23 },
24}
25
26#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
27pub enum InsertPlace {
28 Before,
29 After,
30 Bof,
32 Eof,
34}
35
36#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
37pub enum InsertMode {
38 Plain,
39 Replacement,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ReplacementGroup {
47 pub insert_indices: Vec<usize>,
48 pub delete_indices: Vec<usize>,
49 pub payload: Vec<String>,
50 pub start_line: usize,
51 pub end_line: usize,
52 pub op_index: usize,
53}
54
55pub fn find_replacement_group(edits: &[LineEdit], start: usize) -> Option<ReplacementGroup> {
57 let LineEdit::Insert {
58 anchor,
59 place: InsertPlace::Before,
60 mode: InsertMode::Replacement,
61 op_index,
62 ..
63 } = edits.get(start)?
64 else {
65 return None;
66 };
67 let anchor_line = *anchor;
68 let op_index = *op_index;
69 let mut insert_indices = Vec::new();
70 let mut payload = Vec::new();
71 let mut i = start;
72 while i < edits.len() {
73 match &edits[i] {
74 LineEdit::Insert {
75 anchor,
76 place: InsertPlace::Before,
77 text,
78 mode: InsertMode::Replacement,
79 op_index: edit_op,
80 } if *anchor == anchor_line && *edit_op == op_index => {
81 insert_indices.push(i);
82 payload.push(text.clone());
83 i += 1;
84 }
85 _ => break,
86 }
87 }
88 let mut delete_indices = Vec::new();
89 let mut expected = anchor_line;
90 while i < edits.len() {
91 match &edits[i] {
92 LineEdit::Delete {
93 line,
94 op_index: edit_op,
95 } if *line == expected && *edit_op == op_index => {
96 delete_indices.push(i);
97 expected += 1;
98 i += 1;
99 }
100 _ => break,
101 }
102 }
103 if delete_indices.is_empty() {
104 return None;
105 }
106 Some(ReplacementGroup {
107 insert_indices,
108 delete_indices,
109 payload,
110 start_line: anchor_line,
111 end_line: expected - 1,
112 op_index,
113 })
114}
115
116pub fn coalesce_replacement_edits(edits: &[LineEdit]) -> Vec<LineEdit> {
121 if edits.is_empty() {
122 return Vec::new();
123 }
124
125 let mut op_order: Vec<usize> = Vec::new();
127 let mut by_op: std::collections::BTreeMap<usize, Vec<LineEdit>> =
128 std::collections::BTreeMap::new();
129 for edit in edits {
130 let op = match edit {
131 LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. } => *op_index,
132 };
133 if !by_op.contains_key(&op) {
134 op_order.push(op);
135 }
136 by_op.entry(op).or_default().push(edit.clone());
137 }
138
139 let mut out = Vec::with_capacity(edits.len());
140 for op in op_order {
141 let group = by_op.remove(&op).unwrap_or_default();
142 out.extend(coalesce_one_op(group));
143 }
144 out
145}
146
147fn coalesce_one_op(edits: Vec<LineEdit>) -> Vec<LineEdit> {
148 let mut deletes: Vec<usize> = edits
149 .iter()
150 .filter_map(|edit| match edit {
151 LineEdit::Delete { line, .. } => Some(*line),
152 _ => None,
153 })
154 .collect();
155 let replacements: Vec<String> = edits
156 .iter()
157 .filter_map(|edit| match edit {
158 LineEdit::Insert {
159 text,
160 mode: InsertMode::Replacement,
161 ..
162 } => Some(text.clone()),
163 _ => None,
164 })
165 .collect();
166 let plain: Vec<LineEdit> = edits
167 .iter()
168 .filter(|edit| {
169 !matches!(
170 edit,
171 LineEdit::Delete { .. }
172 | LineEdit::Insert {
173 mode: InsertMode::Replacement,
174 ..
175 }
176 )
177 })
178 .cloned()
179 .collect();
180
181 if deletes.is_empty() || replacements.is_empty() {
182 return edits;
183 }
184
185 deletes.sort_unstable();
186 deletes.dedup();
187 let contiguous = deletes
189 .windows(2)
190 .all(|pair| pair[1] == pair[0].saturating_add(1));
191 if !contiguous {
192 return edits;
193 }
194 let start = deletes[0];
195 let end = *deletes.last().unwrap();
196 let op_index = match edits.first() {
197 Some(LineEdit::Insert { op_index, .. } | LineEdit::Delete { op_index, .. }) => *op_index,
198 None => 0,
199 };
200
201 let mut coalesced = plain;
202 for text in replacements {
203 coalesced.push(LineEdit::Insert {
204 anchor: start,
205 place: InsertPlace::Before,
206 text,
207 mode: InsertMode::Replacement,
208 op_index,
209 });
210 }
211 for line in start..=end {
212 coalesced.push(LineEdit::Delete { line, op_index });
213 }
214 coalesced
215}
216
217pub fn materialize_edits(original_lines: &[String], edits: &[LineEdit]) -> Vec<String> {
219 let mut file_lines = original_lines.to_vec();
220 let mut bof: Vec<String> = Vec::new();
221 let mut eof: Vec<String> = Vec::new();
222
223 let mut by_line: std::collections::BTreeMap<usize, Vec<&LineEdit>> =
225 std::collections::BTreeMap::new();
226 for edit in edits {
227 match edit {
228 LineEdit::Insert {
229 place: InsertPlace::Bof,
230 text,
231 ..
232 } => bof.push(text.clone()),
233 LineEdit::Insert {
234 place: InsertPlace::Eof,
235 text,
236 ..
237 } => eof.push(text.clone()),
238 LineEdit::Insert { anchor, .. } => {
239 by_line.entry(*anchor).or_default().push(edit);
240 }
241 LineEdit::Delete { line, .. } => {
242 by_line.entry(*line).or_default().push(edit);
243 }
244 }
245 }
246
247 let lines: Vec<usize> = by_line.keys().copied().collect();
249 for line in lines.into_iter().rev() {
250 let Some(bucket) = by_line.get(&line) else {
251 continue;
252 };
253 let idx = line.saturating_sub(1);
254 if idx > file_lines.len() {
255 continue;
256 }
257 let current = file_lines.get(idx).cloned().unwrap_or_default();
258 let mut before = Vec::new();
259 let mut after = Vec::new();
260 let mut replacement = Vec::new();
261 let mut delete_line = false;
262 for edit in bucket {
263 match edit {
264 LineEdit::Insert {
265 place: InsertPlace::Before,
266 text,
267 mode: InsertMode::Replacement,
268 ..
269 } => replacement.push(text.clone()),
270 LineEdit::Insert {
271 place: InsertPlace::Before,
272 text,
273 ..
274 } => before.push(text.clone()),
275 LineEdit::Insert {
276 place: InsertPlace::After,
277 text,
278 ..
279 } => after.push(text.clone()),
280 LineEdit::Delete { .. } => delete_line = true,
281 LineEdit::Insert {
282 place: InsertPlace::Bof | InsertPlace::Eof,
283 ..
284 } => {}
285 }
286 }
287 if before.is_empty() && replacement.is_empty() && after.is_empty() && !delete_line {
288 continue;
289 }
290 let spliced = if delete_line {
291 let mut rows = before;
292 rows.extend(replacement);
293 rows.extend(after);
294 rows
295 } else {
296 let mut rows = before;
297 rows.extend(replacement);
298 if idx < file_lines.len() {
299 rows.push(current);
300 }
301 rows.extend(after);
302 rows
303 };
304 if idx < file_lines.len() {
305 file_lines.splice(idx..=idx, spliced);
306 } else {
307 file_lines.extend(spliced);
308 }
309 }
310
311 if !bof.is_empty() {
312 let mut rows = bof;
313 rows.append(&mut file_lines);
314 file_lines = rows;
315 }
316 file_lines.extend(eof);
317 file_lines
318}
319
320pub fn join_lines(
322 lines: &[String],
323 default_terminator: TerminatorKind,
324 trailing_terminator: bool,
325) -> Vec<u8> {
326 if lines.is_empty() {
327 return Vec::new();
328 }
329 let mut out = Vec::new();
330 for (index, line) in lines.iter().enumerate() {
331 out.extend_from_slice(line.as_bytes());
332 let is_last = index + 1 == lines.len();
333 let term = if is_last && !trailing_terminator {
334 TerminatorKind::None
335 } else if default_terminator == TerminatorKind::None {
336 TerminatorKind::Lf
337 } else {
338 default_terminator
339 };
340 match term {
341 TerminatorKind::Lf => out.push(b'\n'),
342 TerminatorKind::CrLf => out.extend_from_slice(b"\r\n"),
343 TerminatorKind::None => {}
344 }
345 }
346 out
347}
348
349pub fn terminator_policy(
351 records: &std::collections::BTreeMap<usize, crate::hashline::scan::RawLineRecord>,
352) -> (TerminatorKind, bool) {
353 let mut lf = 0usize;
354 let mut crlf = 0usize;
355 let mut trailing = false;
356 let last = records.keys().next_back().copied();
357 for (&line, record) in records {
358 match record.terminator {
359 TerminatorKind::Lf => lf += 1,
360 TerminatorKind::CrLf => crlf += 1,
361 TerminatorKind::None => {}
362 }
363 if Some(line) == last {
364 trailing = record.terminator != TerminatorKind::None;
365 }
366 }
367 let default = if crlf > lf {
368 TerminatorKind::CrLf
369 } else if lf > 0 || crlf > 0 {
370 TerminatorKind::Lf
371 } else {
372 TerminatorKind::Lf
373 };
374 (default, trailing || records.is_empty())
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn materialize_replaces_a_span() {
383 let original = vec!["a".into(), "b".into(), "c".into()];
384 let edits = vec![
385 LineEdit::Insert {
386 anchor: 2,
387 place: InsertPlace::Before,
388 text: "B".into(),
389 mode: InsertMode::Replacement,
390 op_index: 0,
391 },
392 LineEdit::Delete {
393 line: 2,
394 op_index: 0,
395 },
396 ];
397 assert_eq!(
398 materialize_edits(&original, &edits),
399 vec!["a".to_string(), "B".into(), "c".into()]
400 );
401 }
402
403 #[test]
404 fn coalesce_merges_contiguous_single_line_replacements() {
405 let edits = vec![
406 LineEdit::Insert {
407 anchor: 1,
408 place: InsertPlace::Before,
409 text: "A".into(),
410 mode: InsertMode::Replacement,
411 op_index: 0,
412 },
413 LineEdit::Delete {
414 line: 1,
415 op_index: 0,
416 },
417 LineEdit::Insert {
418 anchor: 2,
419 place: InsertPlace::Before,
420 text: "B".into(),
421 mode: InsertMode::Replacement,
422 op_index: 0,
423 },
424 LineEdit::Delete {
425 line: 2,
426 op_index: 0,
427 },
428 LineEdit::Insert {
429 anchor: 3,
430 place: InsertPlace::Before,
431 text: "C".into(),
432 mode: InsertMode::Replacement,
433 op_index: 0,
434 },
435 LineEdit::Delete {
436 line: 3,
437 op_index: 0,
438 },
439 ];
440 let coalesced = coalesce_replacement_edits(&edits);
441 let group = find_replacement_group(&coalesced, 0).expect("one group");
442 assert_eq!(group.start_line, 1);
443 assert_eq!(group.end_line, 3);
444 assert_eq!(group.payload, vec!["A", "B", "C"]);
445 }
446}