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