1#[cfg(test)]
4use super::super::Editor;
5
6#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
8pub struct ReplacementHit {
9 pub line: usize,
10 pub col: usize,
11 pub match_len: usize,
12 pub text: std::sync::Arc<str>,
13}
14
15#[cfg(test)]
16impl Editor {
17 #[cfg(test)]
21 pub(crate) fn replace_in_buffer_pub(
22 &mut self,
23 bi: strop_core::id::DocumentId,
24 hits: &[ReplacementHit],
25 replacement: &str,
26 ) -> (usize, usize, usize) {
27 if self.docs.get(bi).is_none() || self.doc(bi).buf.readonly {
28 return (0, 0, hits.len());
29 }
30 let (edits, stale) = verified_edits(&self.doc(bi).buf, hits, replacement);
31 let applied = edits.len();
32 if edits.is_empty() {
33 return (0, 0, stale);
34 }
35 let base = self.doc(bi).buf.revision();
36 match self.apply(
37 bi,
38 base,
39 crate::editor::transact::ChangeSet {
40 edits,
41 undo_open: false,
42 },
43 ) {
44 Ok(_) => (1, applied, stale),
45 Err(_) => (0, 0, applied + stale), }
47 }
48}
49
50#[cfg(test)]
54fn verified_edits(
55 buf: &strop_core::Buffer,
56 hits: &[ReplacementHit],
57 replacement: &str,
58) -> (Vec<strop_core::Replacement>, usize) {
59 let mut edits = Vec::new();
60 let mut stale = 0;
61 for hit in hits {
62 if let Some(range) = checked_hit_range(buf.text(), hit) {
63 edits.push(strop_core::Replacement::new(range, replacement));
64 } else {
65 stale += 1;
66 }
67 }
68 edits.sort_by_key(|edit| edit.range.start.get());
69 (edits, stale)
70}
71
72pub fn checked_hit_range(buffer: &ropey::Rope, hit: &ReplacementHit) -> Option<strop_core::Range> {
74 if hit.line == 0 || hit.line > buffer.len_lines() {
75 return None;
76 }
77 let start = hit.col.checked_sub(1)?;
78 let end = start.checked_add(hit.match_len)?;
79 let (checked_start, checked_end) =
80 strop_picker::replace_span(&hit.text, hit.col, hit.match_len);
81 if (start, end) != (checked_start, checked_end) {
82 return None;
83 }
84 let line_start = buffer.line_to_byte(hit.line - 1);
85 let mut line_end = if hit.line < buffer.len_lines() {
86 buffer.line_to_byte(hit.line)
87 } else {
88 buffer.len_bytes()
89 };
90 if line_end > line_start && buffer.byte(line_end - 1) == b'\n' {
91 line_end -= 1;
92 }
93 let absolute_start = line_start.checked_add(start)?;
94 let absolute_end = line_start.checked_add(end)?;
95 (absolute_end <= line_end && buffer.byte_slice(line_start..line_end) == hit.text.as_ref())
97 .then(|| strop_core::Range::charwise(absolute_start, absolute_end))
98}
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use std::path::PathBuf;
103 use strop_core::Buffer;
104 use strop_picker::{Item, Payload, Picker};
105
106 fn hit(line: usize, col: usize, len: usize, text: &str) -> ReplacementHit {
108 ReplacementHit {
109 line,
110 col,
111 match_len: len,
112 text: text.into(),
113 }
114 }
115
116 #[test]
117 fn verified_edits_skips_stale_witnesses() {
118 let buf = Buffer::from_text("foo bar foo\n");
119 let hits = vec![
120 hit(1, 9, 3, "foo bar foo"),
121 hit(1, 1, 3, "foo bar foo"),
122 hit(1, 5, 3, "WRONG — stale line"),
123 ];
124 let (edits, stale) = verified_edits(&buf, &hits, "baz");
125 assert_eq!((edits.len(), stale), (2, 1));
126 assert!(edits[0].range.start.get() < edits[1].range.start.get());
127 }
128
129 #[test]
130 fn changed_regex_context_and_empty_matches_refuse_old_hits() {
131 let mut editor = Editor::new(Buffer::from_text("foobar\n"));
132 let source = editor.current();
133 for witness in [hit(1, 1, 3, "foo"), hit(1, 4, 0, "foo")] {
134 assert_eq!(
135 editor.replace_in_buffer_pub(source, &[witness], "bar"),
136 (0, 0, 1)
137 );
138 assert_eq!(editor.buf().text(), "foobar\n");
139 }
140 }
141
142 #[test]
143 fn enter_without_content_expression_is_a_named_refusal() {
144 let mut e = Editor::new(Buffer::from_text("x\n"));
145 e.open_search(true);
146 e.feed_text("language:rust");
147 e.prepare_search_review();
148 assert!(!e.message.is_empty());
149 assert!(e.picker.is_some(), "the picker stays open to fix the query");
150 assert_eq!(e.buf().text(), "x\n");
151 }
152
153 #[test]
154 fn enter_without_matches_mutates_nothing() {
155 let dir = tempfile::tempdir().unwrap();
156 let mut e = Editor::new_in(Buffer::from_text("x\n"), dir.path().to_path_buf());
157 e.open_search(true);
158 e.feed_text("foo");
159 e.wait_picker();
160 e.prepare_search_review();
161 assert!(e.picker.is_some());
162 assert_eq!(e.buf().text(), "x\n");
163 }
164
165 fn text_of(e: &Editor, document: strop_core::id::DocumentId) -> String {
166 e.docs.get(document).unwrap().buf.text().to_string()
167 }
168
169 #[test]
175 fn replace_review_apply_and_save_change_flow() {
176 let dir = tempfile::tempdir().unwrap();
177 let a = dir.path().join("a.txt");
178 let b = dir.path().join("b.txt");
179 std::fs::write(&a, "alpha foo\nbeta foo\ngamma\n").unwrap();
180 std::fs::write(&b, "foo one\n").unwrap();
181 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
182 let a_doc = e.open_fixture(&a).unwrap();
183
184 e.feed_text(" Rfoo");
185 e.wait_picker();
186 assert_eq!(e.picker.as_ref().unwrap().picker.items.len(), 3);
187 e.feed(crate::editor::Key::Tab);
188 e.feed_text("bar");
189 e.accept_current_picker();
190 e.wait_io().unwrap();
193 assert_eq!(e.buf().name.as_deref(), Some("change proposal 1"));
194 let review = e.current();
195 let proposal = e.buf().text().to_string();
196 assert!(
197 proposal.contains("strop change proposal 1: replace"),
198 "{proposal}"
199 );
200 assert!(proposal.contains("-alpha foo"), "{proposal}");
201 assert!(proposal.contains("+alpha bar"), "{proposal}");
202 assert!(proposal.contains("-beta foo"), "{proposal}");
203 assert!(proposal.contains("-foo one"), "{proposal}");
204 assert!(proposal.contains("+bar one"), "{proposal}");
205 assert!(proposal.contains(":apply-change"), "{proposal}");
206 assert_eq!(text_of(&e, a_doc), "alpha foo\nbeta foo\ngamma\n");
207 let b_doc = e
208 .docs
209 .iter()
210 .find_map(|(id, doc)| (doc.buf.path.as_deref() == Some(b.as_path())).then_some(id))
211 .expect("the unopened target loaded as a real buffer");
212 assert_eq!(text_of(&e, b_doc), "foo one\n");
213 assert!(!e.io_pending(), "the review schedules no saves");
214
215 e.review_apply_pub();
216 assert_eq!(text_of(&e, a_doc), "alpha bar\nbeta bar\ngamma\n");
217 assert_eq!(text_of(&e, b_doc), "bar one\n");
218 assert!(e.docs.get(a_doc).unwrap().buf.dirty);
219 assert!(e.docs.get(b_doc).unwrap().buf.dirty);
220 assert!(!e.io_pending(), "apply schedules no saves");
222 assert_eq!(
223 std::fs::read_to_string(&a).unwrap(),
224 "alpha foo\nbeta foo\ngamma\n"
225 );
226 assert_eq!(std::fs::read_to_string(&b).unwrap(), "foo one\n");
227 let receipt = text_of(&e, review);
228 assert!(receipt.contains("— APPLIED"), "{receipt}");
229 assert!(receipt.contains("a.txt"), "{receipt}");
230 assert!(receipt.contains("b.txt"), "{receipt}");
231
232 let c = dir.path().join("c.txt");
236 std::fs::write(&c, "unrelated\n").unwrap();
237 let c_doc = e.open_fixture(&c).unwrap();
238 e.feed_text("Ihand edited \x1b");
239 assert!(e.docs.get(c_doc).unwrap().buf.dirty);
240 e.save_changed_files_pub();
241 e.wait_io().unwrap();
242 assert_eq!(
243 std::fs::read_to_string(&a).unwrap(),
244 "alpha bar\nbeta bar\ngamma\n"
245 );
246 assert_eq!(std::fs::read_to_string(&b).unwrap(), "bar one\n");
247 assert!(!e.docs.get(a_doc).unwrap().buf.dirty);
248 assert!(!e.docs.get(b_doc).unwrap().buf.dirty);
249 assert_eq!(
250 std::fs::read_to_string(&c).unwrap(),
251 "unrelated\n",
252 ":save-change saves exactly the change-plan documents"
253 );
254 assert!(e.docs.get(c_doc).unwrap().buf.dirty);
255
256 e.feed_text(":undo-change\r");
258 assert_eq!(text_of(&e, a_doc), "alpha foo\nbeta foo\ngamma\n");
259 assert_eq!(text_of(&e, b_doc), "foo one\n");
260 }
261
262 #[test]
266 fn dirty_open_buffer_wins_over_disk() {
267 let dir = tempfile::tempdir().unwrap();
268 let a = dir.path().join("a.txt");
269 std::fs::write(&a, "alpha foo\nbeta foo\n").unwrap();
270 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
271 let a_doc = e.open_fixture(&a).unwrap();
272 e.feed_text(" Rfoo");
273 e.wait_picker();
274 assert_eq!(e.picker.as_ref().unwrap().picker.items.len(), 2);
275 e.close_picker();
278 e.switch_to(a_doc);
279 e.feed_text("ichanged <esc>");
280 e.open_search(true);
282 e.wait_picker();
283 e.feed_text("bar");
284 e.accept_current_picker();
285 e.wait_io().unwrap();
286 assert_eq!(e.buf().name.as_deref(), Some("change proposal 1"));
287 let proposal = e.buf().text().to_string();
288 assert!(proposal.contains("+beta bar"), "{proposal}");
289 e.review_apply_pub();
290 assert_eq!(text_of(&e, a_doc), "changed alpha bar\nbeta bar\n");
291 }
292 #[test]
293 fn readonly_target_is_named_not_bulk_written() {
294 let dir = tempfile::tempdir().unwrap();
295 let a = dir.path().join("a.txt");
296 std::fs::write(&a, "alpha foo\n").unwrap();
297 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
298 let a_doc = e.open_fixture(&a).unwrap();
299 e.doc_mut(a_doc).buf.readonly = true;
300 e.feed_text(" Rfoo");
301 e.wait_picker();
302 assert_eq!(e.picker.as_ref().unwrap().picker.items.len(), 1);
303 e.feed(crate::editor::Key::Tab);
304 e.feed_text("bar");
305 e.prepare_search_review();
306 e.wait_io().unwrap();
307 let review = e.buf().text().to_string();
308 assert!(review.contains("a.txt"), "{review}");
309 assert!(review.contains("read-only"), "{review}");
310 e.review_apply_pub();
311 assert_eq!(text_of(&e, a_doc), "alpha foo\n");
312 }
313
314 #[test]
315 fn failed_open_is_named_in_review_and_receipt() {
316 let dir = tempfile::tempdir().unwrap();
317 let a = dir.path().join("a.txt");
318 let b = dir.path().join("b.txt");
319 std::fs::write(&a, "alpha foo\n").unwrap();
320 std::fs::write(&b, "foo one\n").unwrap();
321 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
322 let a_doc = e.open_fixture(&a).unwrap();
323 e.feed_text(" Rfoo");
324 e.wait_picker();
325 e.feed(crate::editor::Key::Tab);
326 e.feed_text("bar");
327 std::fs::remove_file(&b).unwrap();
330 std::fs::create_dir(&b).unwrap();
331 e.accept_current_picker();
332 e.wait_io().unwrap();
333 assert_eq!(e.buf().name.as_deref(), Some("change proposal 1"));
334 let review = e.current();
335 let proposal = e.buf().text().to_string();
336 assert!(
337 proposal.contains("b.txt"),
338 "failed target named: {proposal}"
339 );
340 e.review_apply_pub();
341 assert_eq!(text_of(&e, a_doc), "alpha bar\n");
342 let receipt = text_of(&e, review);
343 assert!(receipt.contains("refused: "), "{receipt}");
344 assert!(receipt.contains("b.txt"), "{receipt}");
345 }
346
347 #[test]
348 fn apply_refuses_a_target_edited_since_the_review_by_name() {
349 let dir = tempfile::tempdir().unwrap();
350 let a = dir.path().join("a.txt");
351 std::fs::write(&a, "alpha foo\n").unwrap();
352 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
353 let a_doc = e.open_fixture(&a).unwrap();
354 e.feed_text(" Rfoo");
355 e.wait_picker();
356 e.feed(crate::editor::Key::Tab);
357 e.feed_text("bar");
358 e.accept_current_picker();
359 e.wait_io().unwrap();
360 assert_eq!(e.buf().name.as_deref(), Some("change proposal 1"));
361 e.switch_to(a_doc);
363 e.feed_text("Ichanged \x1b");
364 e.review_apply_pub();
365 assert_eq!(
366 text_of(&e, a_doc),
367 "changed alpha foo\n",
368 "a moved target is refused, never recomputed"
369 );
370 let receipt = e
371 .docs
372 .iter()
373 .find_map(|(id, doc)| {
374 (doc.buf.name.as_deref() == Some("change proposal 1")).then_some(id)
375 })
376 .unwrap();
377 let receipt = text_of(&e, receipt);
378 assert!(receipt.contains("refused: "), "{receipt}");
379 assert!(
380 receipt.contains("a.txt"),
381 "the stale target is named: {receipt}"
382 );
383 assert!(receipt.contains("edited since the proposal"), "{receipt}");
384 }
385
386 #[test]
387 fn cancel_mutates_nothing() {
388 let dir = tempfile::tempdir().unwrap();
389 let a = dir.path().join("a.txt");
390 std::fs::write(&a, "alpha foo\n").unwrap();
391 let mut e = Editor::new_in(Buffer::from_text("scratch\n"), dir.path().to_path_buf());
392 let a_doc = e.open_fixture(&a).unwrap();
393 e.feed_text(" Rfoo");
394 e.wait_picker();
395 e.feed(crate::editor::Key::Tab);
396 e.feed_text("bar");
397 e.accept_current_picker();
398 e.wait_io().unwrap();
399 assert_eq!(e.buf().name.as_deref(), Some("change proposal 1"));
400 e.review_cancel_pub();
401 assert_eq!(text_of(&e, a_doc), "alpha foo\n");
402 assert!(!e.docs.get(a_doc).unwrap().buf.dirty);
403 e.wait_picker();
404 assert!(!e.io_pending(), "cancel schedules no saves");
405 assert_eq!(std::fs::read_to_string(&a).unwrap(), "alpha foo\n");
406 }
407
408 #[test]
409 fn excluded_rows_stay_out_of_the_apply_set() {
410 let mut p = Picker::search(
411 vec![
412 Item {
413 badge: None,
414 text: "a".into(),
415 payload: Payload::Grep {
416 path: PathBuf::from("a"),
417 line: 1,
418 col: 1,
419 match_len: 1,
420 line_text: "x".into(),
421 },
422 },
423 Item {
424 badge: None,
425 text: "b".into(),
426 payload: Payload::Grep {
427 path: PathBuf::from("b"),
428 line: 1,
429 col: 1,
430 match_len: 1,
431 line_text: "y".into(),
432 },
433 },
434 ],
435 false,
436 true,
437 );
438 p.install_ranking(
439 strop_picker::rank::rank(&p.filter_request(), || false)
440 .unwrap()
441 .unwrap(),
442 );
443 p.toggle_excluded(); assert_eq!(p.accepted().count(), 1);
445 assert_eq!(p.accepted().next().unwrap().text, "b");
446 p.toggle_excluded(); assert_eq!(p.accepted().count(), 2);
448 }
449
450 #[test]
451 fn replace_filters_narrow_the_apply_set() {
452 let dir = tempfile::tempdir().unwrap();
455 std::fs::write(dir.path().join("a.rs"), "foo one\n").unwrap();
456 std::fs::write(dir.path().join("b.txt"), "foo two\n").unwrap();
457 std::fs::write(dir.path().join("c.py"), "foo three\n").unwrap();
458 let mut e = Editor::new(Buffer::from_text("x\n"));
459 e.cwd = dir.path().to_path_buf();
460 e.open_search(true);
461 e.feed_text("foo -glob:\"*.py\"");
463 e.wait_picker();
464 let p = &e.picker.as_ref().unwrap().picker;
465 assert_eq!(p.items.len(), 2, "py excluded via -glob:");
466 assert!(p.items.iter().all(|i| !format!("{i:?}").contains("c.py")));
467 }
468}