Skip to main content

strop_engine/editor/
occurrence.rs

1//! Occurrence selection (0049 §7): `gb`/`gB` semantics. With no live
2//! session, the word under the caret (vim's own word classes —
3//! strop-grammar's `word_run`, no second parser) or a nonempty
4//! charwise visual selection (literal text, never pattern syntax)
5//! seeds the session; repeating adds the next unselected literal match
6//! in document order, wrapping at most once. Selections are REAL
7//! stretched ranges riding the visual operator cascade, so motions,
8//! c/d/y, insert and undo treat every occurrence as one logical edit.
9
10use strop_core::id::{BufferRevision, DocumentId};
11use strop_core::selection::Selection;
12use strop_core::Range;
13
14use super::{Editor, Mode};
15
16/// A live occurrence session. `added` is in add order (seed first) and
17/// mirrors the selection set exactly — staleness (edits, external
18/// cursor moves, a buffer switch) is detected on the next call and the
19/// session re-seeds instead of acting on ghosts.
20#[derive(Debug, Clone)]
21pub(crate) struct OccurrenceState {
22    document: DocumentId,
23    revision: BufferRevision,
24    needle: String,
25    added: Vec<(usize, usize)>,
26    /// Candidates passed over by skip: never offered again this session.
27    skipped: Vec<(usize, usize)>,
28    /// Where the next scan starts; `wrapped` arms once at the end.
29    next_from: usize,
30    wrapped: bool,
31}
32
33/// One scan step's answer.
34enum Candidate {
35    /// The match and the scan cursor past it.
36    Found((usize, usize), usize),
37    Exhausted,
38}
39
40/// Message-safe needle: quoted, control characters made visible.
41fn quoted(needle: &str) -> String {
42    format!("\"{}\"", strop_core::layout::printable_text(needle))
43}
44
45impl Editor {
46    /// `gb`: with no live session, select the word under the caret (or
47    /// seed the literal visual selection); otherwise add the next
48    /// unselected match in document order, wrapping at most once.
49    pub fn occurrence_next_pub(&mut self) {
50        if self.buf().readonly {
51            self.message = "readonly buffer".into();
52            return;
53        }
54        let Some(mut state) = self.occurrence_session() else {
55            // the first press seeds — it never adds in the same breath
56            self.occurrence_seed();
57            return;
58        };
59        match self.occurrence_candidate(&mut state) {
60            Candidate::Found(range, next_from) => {
61                state.next_from = next_from;
62                state.added.push(range);
63                let head = self.buf().clamp_boundary(range.1 - 1);
64                self.sels_mut().plant_extra_selection(range.0, head);
65                self.occurrence = Some(state);
66                self.occurrence_message();
67            }
68            Candidate::Exhausted => {
69                let message = format!("no more occurrences of {}", quoted(&state.needle));
70                self.occurrence = Some(state);
71                self.message = message;
72            }
73        }
74    }
75
76    /// `gB`: select every occurrence of the needle in the buffer. The
77    /// seed stays the primary; ranges passed over by skip stay
78    /// unselected.
79    pub fn occurrence_all_pub(&mut self) {
80        if self.buf().readonly {
81            self.message = "readonly buffer".into();
82            return;
83        }
84        let mut state = match self.occurrence_session() {
85            Some(state) => state,
86            None => {
87                // no session: seed the caret's word, then enumerate —
88                // seeding IS half of select-all's job
89                self.occurrence_seed();
90                let Some(state) = self.occurrence.take() else {
91                    return;
92                };
93                state
94            }
95        };
96        // full enumeration from the top; advancing by match END keeps
97        // hits non-overlapping, so no range is ever selected twice
98        let mut all: Vec<(usize, usize)> = Vec::new();
99        let mut from = 0;
100        while let Some((start, end)) = self.occurrence_literal_from(from, state.needle.as_bytes()) {
101            all.push((start, end));
102            from = end;
103        }
104        let seed = state.added[0];
105        state.added = std::iter::once(seed)
106            .chain(
107                all.iter()
108                    .copied()
109                    .filter(|range| *range != seed && !state.skipped.contains(range)),
110            )
111            .collect();
112        state.next_from = self.buf().len_bytes();
113        state.wrapped = true;
114        self.sels_mut().collapse_extras();
115        let extras: Vec<(usize, usize)> = state.added.iter().skip(1).copied().collect();
116        for (start, end) in extras {
117            let head = self.buf().clamp_boundary(end - 1);
118            self.sels_mut().plant_extra_selection(start, head);
119        }
120        self.occurrence = Some(state);
121        self.occurrence_message();
122    }
123
124    /// `:select-skip`: advance the candidate without selecting it — the
125    /// passed-over match is never offered again this session.
126    pub fn occurrence_skip_pub(&mut self) {
127        if self.buf().readonly {
128            self.message = "readonly buffer".into();
129            return;
130        }
131        let Some(mut state) = self.occurrence_session() else {
132            // the first press seeds — there is nothing to skip past yet
133            self.occurrence_seed();
134            return;
135        };
136        match self.occurrence_candidate(&mut state) {
137            Candidate::Found(range, next_from) => {
138                state.next_from = next_from;
139                state.skipped.push(range);
140                let selected = state.added.len();
141                self.occurrence = Some(state);
142                self.message = format!("occurrence skipped ({selected} selected)");
143            }
144            Candidate::Exhausted => {
145                let message = format!("no more occurrences of {}", quoted(&state.needle));
146                self.occurrence = Some(state);
147                self.message = message;
148            }
149        }
150    }
151
152    /// `:select-pop`: drop the last-added occurrence. Popping the seed
153    /// ends the session; a popped range becomes a candidate again.
154    pub fn occurrence_pop_pub(&mut self) {
155        if self
156            .occurrence
157            .as_ref()
158            .is_some_and(|state| !self.occurrence_fresh(state))
159        {
160            self.occurrence = None;
161        }
162        let Some(mut state) = self.occurrence.take() else {
163            self.message = "no occurrence selection".into();
164            return;
165        };
166        let popped = state.added[state.added.len() - 1];
167        if state.added.len() == 1 {
168            // popping the seed ends the session: the caret stays on it
169            self.sels_mut().collapse_primary(popped.0);
170            self.mode = Mode::Normal;
171            self.message = "occurrence selection cleared".into();
172            return;
173        }
174        state.added.pop();
175        state.next_from = popped.0;
176        state.wrapped = false;
177        // the session is fresh, so the popped extra is exactly as planted
178        let head = self.buf().clamp_boundary(popped.1 - 1);
179        self.sels_mut().remove_extra(Selection {
180            anchor: popped.0,
181            head,
182        });
183        self.occurrence = Some(state);
184        self.occurrence_message();
185    }
186
187    /// The live session: Some when one exists and still mirrors the
188    /// selections exactly; None when there is no session or it went
189    /// stale (the caller decides between seeding and refusing).
190    fn occurrence_session(&mut self) -> Option<OccurrenceState> {
191        if self
192            .occurrence
193            .as_ref()
194            .is_some_and(|state| self.occurrence_fresh(state))
195        {
196            return self.occurrence.take();
197        }
198        self.occurrence = None;
199        None
200    }
201
202    /// The session survives only while the selections are exactly its
203    /// ranges in the same buffer at the same revision — any edit,
204    /// motion by other means, or document switch re-seeds the next call.
205    fn occurrence_fresh(&self, state: &OccurrenceState) -> bool {
206        if state.document != self.current() || state.revision != self.buf().revision() {
207            return false;
208        }
209        let mut current: Vec<(usize, usize)> = std::iter::once(self.sels().primary())
210            .chain(self.extra_selections().iter().copied())
211            .map(|selection| self.selection_span(selection))
212            .collect();
213        current.sort_unstable();
214        let mut added = state.added.clone();
215        added.sort_unstable();
216        current == added
217    }
218
219    /// Seed from a nonempty charwise visual selection (literal text) or
220    /// the word under the caret. Selects the seed as the stretched
221    /// primary in Visual mode — the operator cascade owns the edits.
222    fn occurrence_seed(&mut self) {
223        let seed = if self.mode == Mode::Visual && self.anchor() != self.head() {
224            // visual seeds keep their direction — no re-stretch
225            self.visual_range()
226                .map(|range| (self.buf().slice_string(range), range, false))
227        } else {
228            strop_grammar::word_run(self.buf(), self.head()).map(|(start, end)| {
229                (
230                    self.buf().slice_string(Range::charwise(start, end)),
231                    Range::charwise(start, end),
232                    true,
233                )
234            })
235        };
236        let Some((needle, range, restretch)) = seed else {
237            self.message = "no word under cursor".into();
238            return;
239        };
240        if needle.is_empty() {
241            self.message = "empty selection — nothing to match".into();
242            return;
243        }
244        let range = (range.start.get(), range.end.get());
245        // 0049 §7.2: in a collection the seed must land in an editable
246        // excerpt body — chrome rows can't seed a selection.
247        if let Some(collection) = self.collections.get(&self.current()) {
248            let inside = collection
249                .excerpts
250                .iter()
251                .any(|e| range.0 >= e.view_start && range.1 <= e.view_end);
252            if !inside {
253                self.message = "not on an editable excerpt".into();
254                return;
255            }
256        }
257        if restretch {
258            let head = self.buf().clamp_boundary(range.1 - 1);
259            self.sels_mut().stretch_primary(range.0, head);
260        }
261        // the session owns the whole selection set
262        self.sels_mut().collapse_extras();
263        self.mode = Mode::Visual;
264        self.occurrence = Some(OccurrenceState {
265            document: self.current(),
266            revision: self.buf().revision(),
267            needle: needle.clone(),
268            added: vec![range],
269            skipped: Vec::new(),
270            next_from: range.1,
271            wrapped: false,
272        });
273        self.message = format!("1 occurrence of {}", quoted(&needle));
274    }
275
276    /// The next unselected, unskipped literal match from the scan
277    /// cursor, wrapping at most once (0049 §7.3). Every step advances
278    /// `next_from` past a hit or wraps once, so this always terminates.
279    fn occurrence_candidate(&self, state: &mut OccurrenceState) -> Candidate {
280        loop {
281            match self.occurrence_literal_from(state.next_from, state.needle.as_bytes()) {
282                Some((start, end)) => {
283                    state.next_from = end;
284                    if state.added.contains(&(start, end)) || state.skipped.contains(&(start, end))
285                    {
286                        continue;
287                    }
288                    return Candidate::Found((start, end), end);
289                }
290                None if !state.wrapped => {
291                    state.wrapped = true;
292                    state.next_from = 0;
293                }
294                None => return Candidate::Exhausted,
295            }
296        }
297    }
298
299    /// The selection-count message every session mutation leaves behind.
300    fn occurrence_message(&mut self) {
301        let Some(state) = &self.occurrence else {
302            return;
303        };
304        let n = state.added.len();
305        self.message = format!(
306            "{n} occurrence{} of {}",
307            if n == 1 { "" } else { "s" },
308            quoted(&state.needle)
309        );
310    }
311}
312
313impl Editor {
314    /// Occurrence scan (0049 §7.2): in a collection, only editable
315    /// excerpt bodies can match — titles, header rows and gaps are
316    /// chrome, never selection targets.
317    fn occurrence_literal_from(&self, from: usize, needle: &[u8]) -> Option<(usize, usize)> {
318        let id = self.current();
319        let Some(collection) = self.collections.get(&id) else {
320            return strop_grammar::literal_from(self.buf(), from, needle);
321        };
322        let mut from = from;
323        loop {
324            let (start, end) = strop_grammar::literal_from(self.buf(), from, needle)?;
325            let inside = collection
326                .excerpts
327                .iter()
328                .any(|e| start >= e.view_start && end <= e.view_end);
329            if inside {
330                return Some((start, end));
331            }
332            if end <= from {
333                return None; // no progress — never spin
334            }
335            from = end;
336        }
337    }
338}