Skip to main content

strop_engine/editor/picker/
replace.rs

1//! Source witnesses shared by Search opening and owned replacement preparation.
2
3#[cfg(test)]
4use super::super::Editor;
5
6/// Exact source-line witness carried across loading and review preparation.
7#[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    /// Verified, single-transaction replacement in an open buffer —
18    /// the test surface for the witness checks review planning shares
19    /// (`verified_edits`); production replace goes through the review.
20    #[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), // raced — report all stale
46        }
47    }
48}
49
50/// Each hit retains its complete source-line witness. This protects regex
51/// context and zero-width matches as well as the replaced bytes. All edits
52/// are prepared against the same unchanged buffer before any are applied.
53#[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
72/// Verify an exact Search witness against authoritative source text.
73pub 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    // Equality to the char-boundary-checked witness establishes source boundaries.
96    (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    /// One exact source witness.
107    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    /// Space R against a temp tree: one open buffer, one unopened
170    /// file. Enter must present one review covering both files without
171    /// mutating anything; apply writes dirty edits through the gateway
172    /// with a receipt and no saves; `:save-change` persists exactly
173    /// those two documents, identically for open and unopened targets.
174    #[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        // the unopened target loads through its owned job, then the
191        // review presents — nothing has mutated yet
192        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        // apply ≠ save: both buffers dirty, both files untouched on disk
221        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        // explicit persistence: open and unopened targets save
233        // identically through the per-document contract — and ONLY
234        // those targets: an unrelated dirty file is left alone
235        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        // the receipt anchors grouped undo of the applied group
257        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    /// Dirty open buffers win over disk (0051 §6 R04): a hit whose
263    /// witness fails against the live buffer text is named stale at
264    /// the review — the plan follows the buffer, not the disk file.
265    #[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        // the buffer drifts from the searched disk text (unsaved) —
276        // the picker must be closed for keys to reach the buffer
277        e.close_picker();
278        e.switch_to(a_doc);
279        e.feed_text("ichanged <esc>");
280        // same search again (disk is unchanged), then Enter
281        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        // the unopened target becomes unreadable before its owned open
328        // lands (a directory never reads as a file)
329        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        // the source moves after the review was prepared
362        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                        location: strop_workspace::ResourceLocation::local(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                        location: strop_workspace::ResourceLocation::local(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(); // excludes row 0
444        assert_eq!(p.accepted().count(), 1);
445        assert_eq!(p.accepted().next().unwrap().text, "b");
446        p.toggle_excluded(); // toggles back
447        assert_eq!(p.accepted().count(), 2);
448    }
449
450    #[test]
451    fn replace_filters_narrow_the_apply_set() {
452        // user ask: extension limiting + file exclusion in Space R —
453        // the qualifier language scopes the hit set the review prepares
454        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        // 0051: the qualifier language, not rg passthrough flags
462        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}