1use std::path::PathBuf;
16
17use similar::{DiffTag, TextDiff};
18use termesh_core::ProposalId;
19use termesh_editor::{ChangeSet, ConflictReason, HunkState, Version};
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Hunk {
28 pub start: usize,
29 pub end: usize,
30 pub text: String,
31 pub state: HunkState,
32}
33
34impl Hunk {
35 pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
36 Self { start, end, text: text.into(), state: HunkState::Clean }
37 }
38
39 pub fn is_insertion(&self) -> bool {
40 self.start == self.end
41 }
42
43 pub fn is_deletion(&self) -> bool {
44 self.text.is_empty()
45 }
46}
47
48#[derive(Debug, Clone)]
55pub struct EditProposal {
56 pub id: ProposalId,
57 pub path: PathBuf,
58 pub base_version: Option<Version>,
62 pub base_text: String,
66 pub proposed_text: String,
68 pub hunks: Vec<Hunk>,
69}
70
71impl EditProposal {
72 pub fn new(
74 id: ProposalId,
75 path: PathBuf,
76 base_version: Option<Version>,
77 base_text: String,
78 proposed_text: String,
79 current_text: &str,
80 ) -> Self {
81 let mut proposal =
82 Self { id, path, base_version, base_text, proposed_text, hunks: Vec::new() };
83 proposal.refresh(current_text);
84 proposal
85 }
86
87 pub fn refresh(&mut self, current_text: &str) {
94 self.hunks = hunks_from_diff(&self.base_text, &self.proposed_text);
95 rebase_hunks(&mut self.hunks, &self.base_text, current_text);
96 }
97 pub fn applicable(&self) -> impl Iterator<Item = &Hunk> {
99 self.hunks.iter().filter(|h| h.state.is_applicable())
100 }
101
102 pub fn has_conflicts(&self) -> bool {
103 self.hunks.iter().any(|h| matches!(h.state, HunkState::Conflicted(_)))
104 }
105
106 pub fn is_settled(&self) -> bool {
108 self.hunks.iter().all(|h| h.state == HunkState::Satisfied)
109 }
110}
111
112fn line_offsets(text: &str) -> Vec<usize> {
118 let mut offsets = Vec::new();
119 let mut acc = 0;
120 for line in text.split_inclusive('\n') {
121 offsets.push(acc);
122 acc += line.chars().count();
123 }
124 offsets.push(acc);
125 offsets
126}
127
128pub fn hunks_from_diff(old: &str, new: &str) -> Vec<Hunk> {
133 let diff = TextDiff::from_lines(old, new);
134 let offsets = line_offsets(old);
135 let new_lines: Vec<&str> = new.split_inclusive('\n').collect();
136
137 diff.ops()
138 .iter()
139 .filter_map(|op| {
140 let (tag, old_range, new_range) = op.as_tag_tuple();
141 if tag == DiffTag::Equal {
142 return None;
143 }
144 Some(Hunk::new(
145 offsets[old_range.start],
146 offsets[old_range.end],
147 new_lines[new_range].concat(),
148 ))
149 })
150 .collect()
151}
152
153pub fn changeset_from_hunks(hunks: &[&Hunk], len_before: usize) -> ChangeSet {
158 let mut ordered: Vec<&&Hunk> = hunks.iter().collect();
159 ordered.sort_by_key(|h| (h.start, h.end));
160
161 let mut builder = ChangeSet::builder(len_before);
162 let mut at = 0;
163 for hunk in ordered {
164 debug_assert!(hunk.start >= at, "hunks overlap: {at} > {}", hunk.start);
165 builder.retain(hunk.start.saturating_sub(at));
166 builder.delete(hunk.end - hunk.start);
167 builder.insert(hunk.text.clone());
168 at = hunk.end;
169 }
170 builder.build()
171}
172
173pub fn rebase_hunks(hunks: &mut [Hunk], base_text: &str, current_text: &str) {
181 if base_text == current_text {
182 return; }
184
185 let human = hunks_from_diff(base_text, current_text);
188 let catchup =
189 changeset_from_hunks(&human.iter().collect::<Vec<_>>(), base_text.chars().count());
190
191 for hunk in hunks.iter_mut() {
192 if human.iter().any(|h| h.start == hunk.start && h.end == hunk.end && h.text == hunk.text) {
202 hunk.state = HunkState::Satisfied;
203 continue;
204 }
205
206 match ConflictReason::from_effect(catchup.touches(hunk.start, hunk.end)) {
207 Some(reason) => hunk.state = HunkState::Conflicted(reason),
208 None => {
209 hunk.start = catchup.map_pos(hunk.start, termesh_editor::Assoc::After);
210 hunk.end = catchup.map_pos(hunk.end, termesh_editor::Assoc::After);
211 }
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 fn texts(hunks: &[Hunk]) -> Vec<(usize, usize, &str)> {
221 hunks.iter().map(|h| (h.start, h.end, h.text.as_str())).collect()
222 }
223
224 fn apply(base: &str, hunks: &[Hunk]) -> String {
226 let clean: Vec<&Hunk> = hunks.iter().filter(|h| h.state.is_applicable()).collect();
227 let cs = changeset_from_hunks(&clean, base.chars().count());
228 cs.apply(&ropey::Rope::from_str(base)).to_string()
229 }
230
231 #[test]
234 fn an_unchanged_file_yields_no_hunks() {
235 assert!(hunks_from_diff("a\nb\n", "a\nb\n").is_empty());
236 }
237
238 #[test]
239 fn a_changed_line_becomes_one_hunk_over_its_char_range() {
240 let old = "one\ntwo\nthree\n";
241 let hunks = hunks_from_diff(old, "one\nTWO\nthree\n");
242 assert_eq!(texts(&hunks), [(4, 8, "TWO\n")]);
243 assert_eq!(apply(old, &hunks), "one\nTWO\nthree\n");
244 }
245
246 #[test]
247 fn an_inserted_line_is_a_zero_width_hunk() {
248 let old = "one\nthree\n";
249 let hunks = hunks_from_diff(old, "one\ntwo\nthree\n");
250 assert_eq!(hunks.len(), 1);
251 assert!(hunks[0].is_insertion(), "nothing is replaced, so the range is empty");
252 assert_eq!(apply(old, &hunks), "one\ntwo\nthree\n");
253 }
254
255 #[test]
256 fn a_deleted_line_is_a_hunk_with_no_replacement() {
257 let old = "one\ntwo\nthree\n";
258 let hunks = hunks_from_diff(old, "one\nthree\n");
259 assert_eq!(hunks.len(), 1);
260 assert!(hunks[0].is_deletion());
261 assert_eq!(apply(old, &hunks), "one\nthree\n");
262 }
263
264 #[test]
265 fn separate_edits_become_separate_hunks() {
266 let old = "one\ntwo\nthree\nfour\n";
268 let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
269 assert_eq!(hunks.len(), 2);
270 assert_eq!(apply(old, &hunks), "ONE\ntwo\nthree\nFOUR\n");
271 }
272
273 #[test]
274 fn accepting_only_one_hunk_leaves_the_other_alone() {
275 let old = "one\ntwo\nthree\nfour\n";
276 let hunks = hunks_from_diff(old, "ONE\ntwo\nthree\nFOUR\n");
277
278 let cs = changeset_from_hunks(&[&hunks[0]], old.chars().count());
279 assert_eq!(cs.apply(&ropey::Rope::from_str(old)).to_string(), "ONE\ntwo\nthree\nfour\n");
280 }
281
282 #[test]
283 fn creating_a_file_from_nothing_is_one_insertion() {
284 let hunks = hunks_from_diff("", "hello\n");
285 assert_eq!(texts(&hunks), [(0, 0, "hello\n")]);
286 assert_eq!(apply("", &hunks), "hello\n");
287 }
288
289 #[test]
290 fn a_file_without_a_trailing_newline_round_trips() {
291 let old = "one\ntwo";
292 let hunks = hunks_from_diff(old, "one\nTWO");
293 assert_eq!(apply(old, &hunks), "one\nTWO");
294 }
295
296 #[test]
297 fn multibyte_lines_produce_char_offsets_not_byte_offsets() {
298 let old = "héllo\nwörld\n";
299 let hunks = hunks_from_diff(old, "héllo\nWORLD\n");
300 assert_eq!(hunks[0].start, 6, "6 chars, not 7 bytes");
301 assert_eq!(apply(old, &hunks), "héllo\nWORLD\n");
302 }
303
304 #[test]
307 fn an_untouched_proposal_needs_no_rebasing() {
308 let base = "one\ntwo\n";
309 let mut hunks = hunks_from_diff(base, "one\nTWO\n");
310 let before = hunks.clone();
311 rebase_hunks(&mut hunks, base, base);
312 assert_eq!(hunks, before);
313 }
314
315 #[test]
318 fn a_hunk_rides_over_an_edit_made_above_it() {
319 let base = "one\ntwo\nthree\n";
320 let current = "zero\none\ntwo\nthree\n"; let mut hunks = hunks_from_diff(base, "one\ntwo\nTHREE\n");
322
323 rebase_hunks(&mut hunks, base, current);
324
325 assert_eq!(hunks[0].state, HunkState::Clean);
326 assert_eq!(apply(current, &hunks), "zero\none\ntwo\nTHREE\n");
327 }
328
329 #[test]
331 fn a_hunk_the_human_edited_inside_conflicts() {
332 let base = "one\ntwo\nthree\n";
333 let current = "one\ntwo EDITED\nthree\n";
334 let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
335
336 rebase_hunks(&mut hunks, base, current);
337
338 assert!(matches!(hunks[0].state, HunkState::Conflicted(_)), "got {:?}", hunks[0].state);
339 assert_eq!(apply(current, &hunks), current, "and nothing is applied");
340 }
341
342 #[test]
344 fn a_change_the_human_already_made_resolves_itself() {
345 let base = "one\ntwo\nthree\n";
346 let current = "one\nTWO\nthree\n"; let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
348
349 rebase_hunks(&mut hunks, base, current);
350
351 assert_eq!(hunks[0].state, HunkState::Satisfied, "not a conflict — already done");
352 assert_eq!(apply(current, &hunks), current, "and applying it would not duplicate it");
353 }
354
355 #[test]
359 fn common_replacement_text_elsewhere_does_not_count_as_already_done() {
360 let base = "fn a() {\n}\nfn b() {\n x\n}\n";
361 let current = "fn a() {\n}\nfn b() {\n}\n";
364 let mut hunks = hunks_from_diff(base, "fn a() {\n}\nfn b() {\n x\n y\n}\n");
366
367 rebase_hunks(&mut hunks, base, current);
368
369 assert!(
370 hunks.iter().all(|h| h.state != HunkState::Satisfied),
371 "a coincidental match must not swallow the change: {:?}",
372 hunks.iter().map(|h| h.state).collect::<Vec<_>>()
373 );
374 }
375
376 #[test]
377 fn a_larger_human_edit_covering_the_same_change_conflicts_rather_than_settling() {
378 let base = "one\ntwo\nthree\n";
379 let current = "one\nTWO\nTHREE\n";
381 let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
382
383 rebase_hunks(&mut hunks, base, current);
384 assert!(
385 matches!(hunks[0].state, HunkState::Conflicted(_)),
386 "not an identical change, so it needs a human decision"
387 );
388 }
389
390 #[test]
391 fn one_conflicted_hunk_does_not_invalidate_its_siblings() {
392 let base = "one\ntwo\nthree\nfour\n";
394 let current = "one\ntwo EDITED\nthree\nfour\n";
395 let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\nFOUR\n");
396 assert_eq!(hunks.len(), 2);
397
398 rebase_hunks(&mut hunks, base, current);
399
400 assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
401 assert_eq!(hunks[1].state, HunkState::Clean, "the unrelated change still applies");
402 assert_eq!(apply(current, &hunks), "one\ntwo EDITED\nthree\nFOUR\n");
403 }
404
405 #[test]
406 fn a_hunk_whose_lines_were_deleted_conflicts() {
407 let base = "one\ntwo\nthree\n";
408 let current = "one\nthree\n"; let mut hunks = hunks_from_diff(base, "one\nTWO\nthree\n");
410
411 rebase_hunks(&mut hunks, base, current);
412 assert!(matches!(hunks[0].state, HunkState::Conflicted(_)));
413 }
414
415 #[test]
416 fn a_proposal_reports_whether_anything_is_left_to_review() {
417 let base = "one\n";
418 let mut proposal = EditProposal::new(
419 ProposalId::new(1),
420 PathBuf::from("/proj/a.rs"),
421 Some(Version(3)),
422 base.into(),
423 "ONE\n".into(),
424 base,
425 );
426 assert!(!proposal.is_settled());
427 assert!(!proposal.has_conflicts());
428 assert_eq!(proposal.applicable().count(), 1);
429
430 proposal.hunks[0].state = HunkState::Satisfied;
431 assert!(proposal.is_settled());
432 assert_eq!(proposal.applicable().count(), 0);
433 }
434
435 #[test]
438 fn refreshing_is_idempotent_and_derived_from_the_original() {
439 let base = "one\ntwo\nthree\n";
440 let mut proposal = EditProposal::new(
441 ProposalId::new(1),
442 PathBuf::from("/a"),
443 None,
444 base.into(),
445 "one\nTWO\nthree\n".into(),
446 base,
447 );
448 assert_eq!(proposal.hunks[0].state, HunkState::Clean);
449
450 proposal.refresh("one\ntwo EDITED\nthree\n");
452 let conflicted = proposal.hunks.clone();
453 assert!(matches!(conflicted[0].state, HunkState::Conflicted(_)));
454
455 proposal.refresh("one\ntwo EDITED\nthree\n");
457 assert_eq!(proposal.hunks, conflicted, "refresh must be idempotent");
458
459 proposal.refresh(base);
462 assert_eq!(proposal.hunks[0].state, HunkState::Clean, "a conflict is not permanent");
463 }
464}