lattice_core/search.rs
1// SAFETY policy: this module opts into `unsafe` for one specific
2// call -- `std::str::from_utf8_unchecked` on the streaming search
3// window. Justified inline at the call site (`find_in_window`)
4// with the UTF-8 invariant the rope and drain logic preserve. No
5// other `unsafe` is permitted in this file; new uses must
6// document the invariant and pass review.
7#![allow(unsafe_code)]
8
9//! Buffer-level regex search.
10//!
11//! Uses [`fancy_regex::Regex`] -- a hybrid engine that delegates to
12//! the `regex` crate's RE2-style DFA for patterns without
13//! backreferences/lookarounds and falls back to a bounded NFA only
14//! for the patterns that need it. So:
15//!
16//! - Plain literals (`/foo`) hit memmem's SIMD prefilter
17//! transparently. Same speed as the prior B-β literal-only path.
18//! - Standard regex (`/(foo|bar)+`) compiles through the lazy DFA.
19//! Linear-time guarantee.
20//! - Backref patterns (`/(\w+)\s+\1/`) use the NFA with a
21//! configurable recursion limit -- catastrophic-backtracking
22//! patterns abort cleanly instead of locking the editor.
23//!
24//! Replacement-side backrefs (the more common request) are handled
25//! by the `fancy_regex::Regex::replace_all` template syntax (`$1`,
26//! `${name}`); see the substitute path in `lattice-ui-tui::app`.
27//!
28//! ## Streaming model
29//!
30//! The search walks the rope's chunk iterator. A sliding `window:
31//! Vec<u8>` holds the current chunk plus a `MAX_MATCH_LEN`-byte
32//! tail from the previous chunk so cross-chunk matches are caught
33//! in exactly one iteration. Match lengths longer than
34//! `MAX_MATCH_LEN` that span a chunk boundary will be missed --
35//! acceptable for v1 (no editor-search workflow needs >8KB matches).
36//!
37//! ## API shape
38//!
39//! Callers compile the [`fancy_regex::Regex`] once and pass it by
40//! reference. Hlsearch / live-preview consumers call
41//! [`find_all`] each keystroke; the regex is compiled when the
42//! pattern changes, not per call.
43//!
44//! Semantics (unchanged from the prior literal engine):
45//!
46//! - `Forward`: smallest match-start byte >= `from`. If none, wrap
47//! and return the smallest match-start byte in `[0, from)`.
48//! - `Backward`: largest match-start byte <= `from`. If none,
49//! wrap and return the largest match-start byte > `from`.
50//!
51//! Both directions are inclusive of `from`. To skip the match at
52//! the cursor (vim's `n` after `/`), the caller advances `from` by
53//! one **UTF-8 scalar** before calling -- not one byte. A byte step
54//! lands mid-scalar on non-ASCII text; this module snaps such an
55//! offset back to the containing scalar (see `floor_char_boundary`)
56//! rather than panicking, which would leave `n` re-finding the very
57//! match the caller meant to skip. `dispatch::step_byte` is the
58//! in-tree caller that honours this.
59
60use fancy_regex::Regex;
61use lattice_protocol::CancellationToken;
62use lattice_protocol::position::{Position, Range};
63
64use crate::buffer::Buffer;
65use crate::error::{CoreError, CoreResult};
66
67/// Which way [`find`] scans from its `from` position. Re-exported at the
68/// crate root as `SearchDir`.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Direction {
71 /// Towards the end of the buffer (`/`, `n` after `/`): the first match
72 /// starting at or after `from`, wrapping to the start.
73 Forward,
74 /// Towards the start of the buffer (`?`, `n` after `?`): the last match
75 /// starting at or before `from`, wrapping to the end.
76 Backward,
77}
78
79/// One match returned by [`find`].
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct SearchHit {
82 /// The matched text as a half-open `[start, end)` range of
83 /// `(line, byte)` positions. Empty for a zero-width match (`^`, `\b`).
84 pub range: Range,
85 /// True if the search wrapped around the buffer end (Forward) or
86 /// start (Backward) before finding `range`.
87 pub wrapped: bool,
88}
89
90/// Maximum match length we expect to span a window boundary. After
91/// a search-window finishes, we keep this many trailing bytes for
92/// the next iteration so a match starting in window N and ending
93/// in window N+1 is caught. Matches longer than this AND spanning
94/// a boundary are missed; 8KB is generous for editor patterns.
95const MAX_MATCH_LEN: usize = 8 * 1024;
96
97/// Bytes accumulated from rope chunks before the next regex
98/// `find` call. ropey emits chunks in the 1-16KB range; the regex
99/// engine has ~5µs of per-call setup. Coalescing into ~128KB
100/// windows amortises that to ~one call per 8 chunks. Tuning down
101/// memory (`MAX_MATCH_LEN`-sized windows) trades back to
102/// ~5µs/chunk × N chunks. 128KB hits the L1/L2 boundary on
103/// typical hardware -- bigger windows spill to L3 and the extend
104/// cost exceeds the regex setup savings.
105const SCAN_WINDOW_BYTES: usize = 128 * 1024;
106
107/// Find every occurrence of the regex in the buffer. Returns the
108/// positional ranges in left-to-right order. Empty for matchless
109/// patterns and patterns whose minimum length exceeds the buffer.
110///
111/// Walks the rope's chunk iterator -- never allocates the whole
112/// buffer text. Per-call cost on literal patterns is dominated by
113/// the SIMD prefilter via the `regex` crate's literal extraction.
114///
115/// `cancel` is polled between matches and at chunk boundaries
116/// inside the inner walks. A flipped token short-circuits with
117/// [`CoreError::Cancelled`]; partial results are discarded.
118///
119/// Matches never overlap: scanning resumes at the end of each match (one
120/// UTF-8 scalar further for a zero-width match).
121///
122/// # Examples
123///
124/// ```
125/// use fancy_regex::Regex;
126/// use lattice_core::Buffer;
127/// use lattice_core::protocol::CancellationToken;
128/// use lattice_core::protocol::position::{Position, Range};
129/// use lattice_core::search::find_all;
130///
131/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
132/// let buf = Buffer::from_text("foo bar\nbaz foo\n");
133/// let re = Regex::new("fo+")?;
134/// let hits = find_all(&buf, &re, &CancellationToken::never())?;
135/// assert_eq!(
136/// hits,
137/// [
138/// Range::new(Position::new(0, 0), Position::new(0, 3)),
139/// Range::new(Position::new(1, 4), Position::new(1, 7)),
140/// ],
141/// );
142/// # Ok(())
143/// # }
144/// ```
145pub fn find_all(
146 buffer: &Buffer,
147 regex: &Regex,
148 cancel: &CancellationToken,
149) -> CoreResult<Vec<Range>> {
150 let total = buffer.byte_len() as usize;
151 if total == 0 {
152 return Ok(Vec::new());
153 }
154 let mut hits = Vec::new();
155 let mut next = 0;
156 loop {
157 if cancel.is_cancelled() {
158 return Err(CoreError::Cancelled);
159 }
160 match find_forward_in_rope(buffer.rope(), regex, next, cancel)? {
161 Some((start_b, end_b)) => {
162 let start = buffer.byte_to_position(start_b)?;
163 let end = buffer.byte_to_position(end_b)?;
164 hits.push(Range::new(start, end));
165 // Advance past the match to avoid overlapping. If the
166 // match was zero-width (e.g. `^`, `\b`, or the empty
167 // alternation in `/|`), still advance so we make
168 // progress -- by a whole UTF-8 scalar, not one byte.
169 // A byte step lands mid-scalar on non-ASCII text and
170 // panics ropey's `byte_slice` next iteration.
171 next = if end_b > start_b {
172 end_b
173 } else {
174 ceil_char_boundary(buffer.rope(), start_b + 1)
175 };
176 if next >= total {
177 break;
178 }
179 }
180 None => break,
181 }
182 }
183 Ok(hits)
184}
185
186/// Find the next match of `regex` from `from` in `direction`, wrapping
187/// around the buffer once. Re-exported at the crate root as `search_find`.
188///
189/// Inclusive of `from` in both directions (see the module docs): a match
190/// starting exactly at `from` is returned, so a caller implementing vim's
191/// `n` advances `from` by one UTF-8 scalar first. `None` when the buffer is
192/// empty or has no match at all. [`SearchHit::wrapped`] reports whether
193/// the hit was found only after wrapping.
194///
195/// # Errors
196///
197/// [`CoreError::Cancelled`] if `cancel` flips mid-scan;
198/// [`CoreError::Protocol`] if `from` is out of bounds (see
199/// [`Buffer::position_to_byte`]).
200///
201/// # Examples
202///
203/// ```
204/// use fancy_regex::Regex;
205/// use lattice_core::protocol::CancellationToken;
206/// use lattice_core::protocol::position::Position;
207/// use lattice_core::search::{Direction, find};
208/// use lattice_core::Buffer;
209///
210/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
211/// let buf = Buffer::from_text("one two one");
212/// let re = Regex::new("one")?;
213/// let never = CancellationToken::never();
214///
215/// // From byte 1, the next "one" is at byte 8 — no wrap needed.
216/// let hit = find(&buf, &re, Position::new(0, 1), Direction::Forward, &never)?;
217/// assert_eq!(hit.map(|h| (h.range.start, h.wrapped)), Some((Position::new(0, 8), false)));
218///
219/// // From byte 9 there is nothing ahead, so the search wraps to byte 0.
220/// let hit = find(&buf, &re, Position::new(0, 9), Direction::Forward, &never)?;
221/// assert_eq!(hit.map(|h| (h.range.start, h.wrapped)), Some((Position::new(0, 0), true)));
222/// # Ok(())
223/// # }
224/// ```
225pub fn find(
226 buffer: &Buffer,
227 regex: &Regex,
228 from: Position,
229 direction: Direction,
230 cancel: &CancellationToken,
231) -> CoreResult<Option<SearchHit>> {
232 let total = buffer.byte_len() as usize;
233 if total == 0 {
234 return Ok(None);
235 }
236 let from_byte = buffer.position_to_byte(from)?;
237 let rope = buffer.rope();
238
239 let (match_range, wrapped) = match direction {
240 Direction::Forward => match find_forward_in_rope(rope, regex, from_byte, cancel)? {
241 Some(r) => (Some(r), false),
242 None => {
243 // Wrap: search [0, from). Filter out matches at
244 // or past `from` -- those would have been seen
245 // by the primary pass.
246 let wrap =
247 find_forward_in_rope(rope, regex, 0, cancel)?.filter(|&(s, _)| s < from_byte);
248 (wrap, true)
249 }
250 },
251 Direction::Backward => match find_backward_in_rope(rope, regex, from_byte, cancel)? {
252 Some(r) => (Some(r), false),
253 None => {
254 // Wrap: largest match in (from, total]. Run a
255 // backward scan from end-of-buffer, filter out
256 // matches at or before from.
257 let last = total.saturating_sub(1);
258 let wrap = find_backward_in_rope(rope, regex, last, cancel)?
259 .filter(|&(s, _)| s > from_byte);
260 (wrap, true)
261 }
262 },
263 };
264
265 match match_range {
266 Some((start_b, end_b)) => {
267 let start_pos = buffer.byte_to_position(start_b)?;
268 let end_pos = buffer.byte_to_position(end_b)?;
269 Ok(Some(SearchHit {
270 range: Range::new(start_pos, end_pos),
271 wrapped,
272 }))
273 }
274 None => Ok(None),
275 }
276}
277
278/// Streaming forward regex search. Returns `Ok(Some((start, end)))`
279/// for the leftmost match at or after `from`, `Ok(None)` if no
280/// match exists. Returns `Err(CoreError::Cancelled)` on a flipped
281/// `cancel` token, or a regex runtime error (e.g. recursion-limit
282/// exceeded on a pathological backref pattern).
283fn find_forward_in_rope(
284 rope: &ropey::Rope,
285 regex: &Regex,
286 from: usize,
287 cancel: &CancellationToken,
288) -> CoreResult<Option<(usize, usize)>> {
289 let total = rope.len_bytes();
290 if from >= total {
291 return Ok(None);
292 }
293 // Defensive net (paramount #1: never panic on a keystroke). A
294 // caller-supplied `from` that lands mid-scalar would panic
295 // `byte_slice`; snap DOWN so the scan starts at the containing
296 // scalar. Reported offsets stay absolute and boundary-aligned.
297 let from = floor_char_boundary(rope, from);
298 let slice = rope.byte_slice(from..total);
299 let mut window: Vec<u8> = Vec::with_capacity(SCAN_WINDOW_BYTES + MAX_MATCH_LEN);
300 let mut window_start_abs = from;
301
302 for chunk in slice.chunks() {
303 // Poll once per chunk: ~1-16KB of work per check, cheaper
304 // than a regex call's ~5µs setup. A flipped token bails
305 // before we extend the window or enter regex.
306 if cancel.is_cancelled() {
307 return Err(CoreError::Cancelled);
308 }
309 window.extend_from_slice(chunk.as_bytes());
310 // Only call into the regex engine once we've accumulated a
311 // full SCAN_WINDOW_BYTES of data. Reduces per-call setup
312 // overhead from once-per-chunk (~5µs × ~800 calls) to
313 // once-per-window (~10µs × ~100 calls) on a 13MB scan.
314 if window.len() < SCAN_WINDOW_BYTES {
315 continue;
316 }
317 if let Some(m) = find_in_window(&window, regex).map_err(regex_to_core_err)? {
318 return Ok(Some((window_start_abs + m.0, window_start_abs + m.1)));
319 }
320 // Slide forward: keep the last MAX_MATCH_LEN bytes so a
321 // match spanning the window boundary is caught next round.
322 // Drain at a UTF-8 char boundary.
323 let drain_target = window.len().saturating_sub(MAX_MATCH_LEN);
324 if drain_target > 0 {
325 let drain_n = round_down_utf8_boundary(&window, drain_target);
326 if drain_n > 0 {
327 window.drain(..drain_n);
328 window_start_abs += drain_n;
329 }
330 }
331 }
332 // Final flush: search whatever remains in the window after the
333 // last chunk (in particular, when the rope ends mid-window).
334 if !window.is_empty()
335 && let Some(m) = find_in_window(&window, regex).map_err(regex_to_core_err)?
336 {
337 return Ok(Some((window_start_abs + m.0, window_start_abs + m.1)));
338 }
339 Ok(None)
340}
341
342/// Run `regex.find` on a window we know is valid UTF-8 (because it
343/// came from rope chunks and our drain logic preserves the
344/// invariant). Skipping the runtime UTF-8 revalidation that
345/// `std::str::from_utf8` would do saves ~µs per call on large
346/// windows -- material at 128KB+ window sizes.
347#[allow(clippy::result_large_err)]
348fn find_in_window(
349 window: &[u8],
350 regex: &Regex,
351) -> Result<Option<(usize, usize)>, fancy_regex::Error> {
352 // SAFETY: rope chunks are typed `&str` (valid UTF-8). The
353 // window is a concat of chunks plus the bridge tail kept by
354 // `round_down_utf8_boundary`, which never splits a codepoint.
355 // The invariant is therefore: `window` is always valid UTF-8.
356 let s = unsafe { std::str::from_utf8_unchecked(window) };
357 match regex.find(s)? {
358 Some(m) => Ok(Some((m.start(), m.end()))),
359 None => Ok(None),
360 }
361}
362
363/// Streaming backward regex search. Returns the rightmost match in
364/// `[0, from + MAX_MATCH_LEN)` clamped to buffer bounds. Walks the
365/// rope's chunks right-to-left. Polls `cancel` once per chunk.
366fn find_backward_in_rope(
367 rope: &ropey::Rope,
368 regex: &Regex,
369 from: usize,
370 cancel: &CancellationToken,
371) -> CoreResult<Option<(usize, usize)>> {
372 let total = rope.len_bytes();
373 if total == 0 {
374 return Ok(None);
375 }
376 // Search window includes any match whose START byte is in
377 // [0, from]. Allow up to MAX_MATCH_LEN past `from` for the
378 // match to extend.
379 // Snap DOWN: `from + MAX_MATCH_LEN` is an arbitrary offset that
380 // lands mid-scalar whenever a multibyte glyph straddles it, and
381 // `byte_slice` panics on that. Rounding down only shortens the
382 // allowance for a match to extend past `from`, never the
383 // `[0, from]` start range the scan is specified over.
384 let end = floor_char_boundary(rope, (from + MAX_MATCH_LEN).min(total));
385 let slice = rope.byte_slice(0..end);
386
387 // ropey's `Chunks` is forward-only; collect to reverse-iterate.
388 // Pointer list only -- chunk content isn't copied.
389 let chunks: Vec<&str> = slice.chunks().collect();
390 let mut window: Vec<u8> = Vec::new();
391 let mut window_end_abs = end;
392
393 for chunk in chunks.iter().rev() {
394 if cancel.is_cancelled() {
395 return Err(CoreError::Cancelled);
396 }
397 let cb = chunk.as_bytes();
398 // Frame: this chunk + bridge from the just-processed (more-
399 // rightward) chunk. The bridge holds at most MAX_MATCH_LEN
400 // bytes so a match spanning into that chunk is reachable.
401 let mut frame = Vec::with_capacity(cb.len() + window.len());
402 frame.extend_from_slice(cb);
403 frame.extend_from_slice(&window);
404 let frame_start_abs = window_end_abs - cb.len();
405
406 // SAFETY: frame is a concat of valid-UTF-8 chunks at
407 // codepoint-aligned boundaries (the `keep` we computed last
408 // iteration was rounded via `round_down_utf8_boundary`).
409 let s = unsafe { std::str::from_utf8_unchecked(&frame) };
410
411 // For "rightmost match starting at or before `from`",
412 // iterate all matches and keep the rightmost whose start
413 // does not exceed `from`. Bounded by total scan cost since
414 // we stop at the chunk granularity.
415 let mut best: Option<(usize, usize)> = None;
416 for m in regex.find_iter(s) {
417 let m = m.map_err(regex_to_core_err)?;
418 let abs_start = frame_start_abs + m.start();
419 // Constrain to the [0, from] start range. Since we
420 // process chunks right-to-left, in the rightmost
421 // chunks `abs_start <= from` always holds (if the
422 // chunk's bytes are all <= from). For the chunk that
423 // straddles `from`, we filter.
424 if abs_start <= from {
425 best = Some((abs_start, frame_start_abs + m.end()));
426 } else {
427 break; // matches are in left-to-right order; later starts > from
428 }
429 }
430 if let Some(found) = best {
431 return Ok(Some(found));
432 }
433
434 // For the next (more-leftward) iteration: keep the first
435 // MAX_MATCH_LEN bytes of `frame` as the new window. They're
436 // the boundary bytes that might end a match starting in
437 // the next chunk we'll process. Round to UTF-8 boundary.
438 let keep_target = frame.len().min(MAX_MATCH_LEN);
439 let keep = round_down_utf8_boundary(&frame, keep_target);
440 window.clear();
441 window.extend_from_slice(&frame[..keep]);
442 window_end_abs -= cb.len();
443 }
444 Ok(None)
445}
446
447/// Round `target` DOWN to the largest index `<= target` that lands
448/// on a UTF-8 codepoint boundary in `bytes`. Assumes `bytes` is
449/// valid UTF-8. Used by the streaming search to ensure the window
450/// drain doesn't split a multi-byte char.
451fn round_down_utf8_boundary(bytes: &[u8], target: usize) -> usize {
452 let target = target.min(bytes.len());
453 let mut i = target;
454 // UTF-8 char boundary: byte is either ASCII (top bit clear) or
455 // a leading byte (top two bits 0b11). Continuation bytes are
456 // 0b10xxxxxx -- step back over them. The end-of-buffer index
457 // (i == bytes.len()) is always a valid boundary; skip indexing
458 // it to avoid OOB.
459 while i > 0 && i < bytes.len() && (bytes[i] & 0b1100_0000) == 0b1000_0000 {
460 i -= 1;
461 }
462 i
463}
464
465/// Round `byte` DOWN to the start of the UTF-8 scalar containing it.
466/// Ropey's `byte_to_char` floors on a mid-scalar index, so the
467/// round-trip through the char index is the snap. Peer of
468/// `buffer::snap_to_char_boundary`, kept local because the search
469/// path works in rope-absolute byte offsets, not `Position`s.
470fn floor_char_boundary(rope: &ropey::Rope, byte: usize) -> usize {
471 let byte = byte.min(rope.len_bytes());
472 rope.char_to_byte(rope.byte_to_char(byte))
473}
474
475/// Round `byte` UP to the next UTF-8 scalar boundary (or the rope
476/// end). Used by the zero-width match advance, which must make
477/// forward progress -- flooring there would loop forever.
478fn ceil_char_boundary(rope: &ropey::Rope, byte: usize) -> usize {
479 let total = rope.len_bytes();
480 if byte >= total {
481 return total;
482 }
483 let floor = floor_char_boundary(rope, byte);
484 if floor == byte {
485 byte
486 } else {
487 rope.char_to_byte(rope.byte_to_char(byte) + 1)
488 }
489}
490
491/// Bridge fancy-regex's runtime error type into `CoreError`.
492/// Pattern compilation errors surface to the App via a different
493/// path (the App compiles up front); this is for runtime errors
494/// like recursion-limit exceeded on a pathological backref pattern.
495fn regex_to_core_err(e: fancy_regex::Error) -> crate::error::CoreError {
496 use lattice_protocol::ProtocolError;
497 crate::error::CoreError::Protocol(ProtocolError::InvalidRange(match e {
498 fancy_regex::Error::ParseError(_, _) => "regex parse error",
499 fancy_regex::Error::CompileError(_) => "regex compile error",
500 fancy_regex::Error::RuntimeError(_) => "regex runtime error (recursion limit?)",
501 _ => "regex error",
502 }))
503}
504
505#[cfg(test)]
506mod tests {
507 #![allow(clippy::unwrap_used, clippy::panic)]
508 use super::*;
509
510 fn p(line: u32, byte: u32) -> Position {
511 Position::new(line, byte)
512 }
513
514 fn r(s: Position, e: Position) -> Range {
515 Range::new(s, e)
516 }
517
518 fn re(pattern: &str) -> Regex {
519 Regex::new(pattern).expect("test pattern compiles")
520 }
521
522 // ---- Public find() ----
523
524 #[test]
525 fn find_in_empty_buffer_returns_none() {
526 let b = Buffer::empty();
527 let hit = find(
528 &b,
529 &re("needle"),
530 Position::ZERO,
531 Direction::Forward,
532 &CancellationToken::never(),
533 )
534 .unwrap();
535 assert!(hit.is_none());
536 }
537
538 #[test]
539 fn forward_basic_match_no_wrap() {
540 let b = Buffer::from_text("hello world");
541 let hit = find(
542 &b,
543 &re("world"),
544 Position::ZERO,
545 Direction::Forward,
546 &CancellationToken::never(),
547 )
548 .unwrap()
549 .expect("match");
550 assert_eq!(hit.range, r(p(0, 6), p(0, 11)));
551 assert!(!hit.wrapped);
552 }
553
554 #[test]
555 fn forward_wraps_when_no_match_after_from() {
556 let b = Buffer::from_text("foo bar baz");
557 let hit = find(
558 &b,
559 &re("foo"),
560 p(0, 5),
561 Direction::Forward,
562 &CancellationToken::never(),
563 )
564 .unwrap()
565 .expect("match");
566 assert_eq!(hit.range, r(p(0, 0), p(0, 3)));
567 assert!(hit.wrapped);
568 }
569
570 #[test]
571 fn forward_no_match_anywhere_is_none() {
572 let b = Buffer::from_text("hello");
573 let hit = find(
574 &b,
575 &re("xyz"),
576 Position::ZERO,
577 Direction::Forward,
578 &CancellationToken::never(),
579 )
580 .unwrap();
581 assert!(hit.is_none());
582 }
583
584 #[test]
585 fn forward_inclusive_of_from_byte() {
586 let b = Buffer::from_text("abc abc");
587 let hit = find(
588 &b,
589 &re("abc"),
590 p(0, 4),
591 Direction::Forward,
592 &CancellationToken::never(),
593 )
594 .unwrap()
595 .expect("match");
596 assert_eq!(hit.range.start, p(0, 4));
597 assert!(!hit.wrapped);
598 }
599
600 #[test]
601 fn backward_basic_match_no_wrap() {
602 let b = Buffer::from_text("foo bar foo");
603 let hit = find(
604 &b,
605 &re("foo"),
606 p(0, 10),
607 Direction::Backward,
608 &CancellationToken::never(),
609 )
610 .unwrap()
611 .expect("match");
612 assert_eq!(hit.range.start, p(0, 8));
613 assert!(!hit.wrapped);
614 }
615
616 #[test]
617 fn backward_wraps_when_no_match_before_from() {
618 let b = Buffer::from_text("alpha beta gamma");
619 let hit = find(
620 &b,
621 &re("gamma"),
622 p(0, 0),
623 Direction::Backward,
624 &CancellationToken::never(),
625 )
626 .unwrap()
627 .expect("match");
628 assert_eq!(hit.range.start, p(0, 11));
629 assert!(hit.wrapped);
630 }
631
632 #[test]
633 fn forward_finds_match_across_lines() {
634 let b = Buffer::from_text("foo\nbar\nbaz");
635 let hit = find(
636 &b,
637 &re("bar"),
638 Position::ZERO,
639 Direction::Forward,
640 &CancellationToken::never(),
641 )
642 .unwrap()
643 .expect("match");
644 assert_eq!(hit.range, r(p(1, 0), p(1, 3)));
645 }
646
647 #[test]
648 fn backward_at_start_of_buffer_finds_match_at_zero() {
649 let b = Buffer::from_text("alpha gamma alpha");
650 let hit = find(
651 &b,
652 &re("alpha"),
653 p(0, 0),
654 Direction::Backward,
655 &CancellationToken::never(),
656 )
657 .unwrap()
658 .expect("match");
659 // from=0: backward primary finds match at 0 itself.
660 assert_eq!(hit.range.start, p(0, 0));
661 assert!(!hit.wrapped);
662 }
663
664 #[test]
665 fn unicode_pattern_matches_at_codepoint_boundary() {
666 let b = Buffer::from_text("café au lait");
667 let hit = find(
668 &b,
669 &re("café"),
670 Position::ZERO,
671 Direction::Forward,
672 &CancellationToken::never(),
673 )
674 .unwrap()
675 .expect("match");
676 // "café" = 5 UTF-8 bytes (c=1, a=1, f=1, é=2)
677 assert_eq!(hit.range, r(p(0, 0), p(0, 5)));
678 }
679
680 // ---- Regex-specific behaviour ----
681
682 #[test]
683 fn regex_alternation_matches_either_branch() {
684 let b = Buffer::from_text("foo bar baz");
685 let hit = find(
686 &b,
687 &re("(bar|baz)"),
688 Position::ZERO,
689 Direction::Forward,
690 &CancellationToken::never(),
691 )
692 .unwrap()
693 .expect("match");
694 assert_eq!(hit.range.start, p(0, 4));
695 }
696
697 #[test]
698 fn regex_character_class_matches() {
699 let b = Buffer::from_text("abc123def");
700 let hit = find(
701 &b,
702 &re(r"\d+"),
703 Position::ZERO,
704 Direction::Forward,
705 &CancellationToken::never(),
706 )
707 .unwrap()
708 .expect("match");
709 assert_eq!(hit.range, r(p(0, 3), p(0, 6)));
710 }
711
712 #[test]
713 fn regex_with_pattern_backref_matches_repeated_word() {
714 // fancy-regex's defining feature vs. plain `regex` crate.
715 let b = Buffer::from_text("the cat the dog");
716 let hit = find(
717 &b,
718 &re(r"(\w+) \w+ \1"),
719 Position::ZERO,
720 Direction::Forward,
721 &CancellationToken::never(),
722 )
723 .unwrap()
724 .expect("match");
725 assert_eq!(hit.range, r(p(0, 0), p(0, 11))); // "the cat the"
726 }
727
728 #[test]
729 fn regex_anchor_matches_start_of_string() {
730 let b = Buffer::from_text("hello world");
731 let hit = find(
732 &b,
733 &re("^hello"),
734 Position::ZERO,
735 Direction::Forward,
736 &CancellationToken::never(),
737 )
738 .unwrap()
739 .expect("match");
740 assert_eq!(hit.range.start, p(0, 0));
741 }
742
743 // ---- find_all ----
744
745 #[test]
746 fn find_all_returns_every_occurrence() {
747 let b = Buffer::from_text("foo bar foo baz foo");
748 let hits = find_all(&b, &re("foo"), &CancellationToken::never()).unwrap();
749 assert_eq!(hits.len(), 3);
750 assert_eq!(hits[0].start, p(0, 0));
751 assert_eq!(hits[1].start, p(0, 8));
752 assert_eq!(hits[2].start, p(0, 16));
753 }
754
755 #[test]
756 fn find_all_no_match_returns_empty() {
757 let b = Buffer::from_text("hello");
758 assert!(
759 find_all(&b, &re("xyz"), &CancellationToken::never())
760 .unwrap()
761 .is_empty()
762 );
763 }
764
765 #[test]
766 fn find_all_does_not_overlap_matches() {
767 let b = Buffer::from_text("aaaa");
768 // Pattern "aa" matches at 0 and 2 (advancing by match.end each time).
769 let hits = find_all(&b, &re("aa"), &CancellationToken::never()).unwrap();
770 assert_eq!(hits.len(), 2);
771 }
772
773 #[test]
774 fn find_all_across_lines() {
775 let b = Buffer::from_text("foo\nbar\nfoo");
776 let hits = find_all(&b, &re("foo"), &CancellationToken::never()).unwrap();
777 assert_eq!(hits.len(), 2);
778 assert_eq!(hits[0].start.line, 0);
779 assert_eq!(hits[1].start.line, 2);
780 }
781
782 #[test]
783 fn find_all_with_regex_class() {
784 let b = Buffer::from_text("a1 b2 c3");
785 let hits = find_all(&b, &re(r"\d"), &CancellationToken::never()).unwrap();
786 assert_eq!(hits.len(), 3);
787 }
788
789 #[test]
790 fn find_all_zero_width_advance_steps_over_multibyte_scalars() {
791 // `/|` (empty alternation) matches zero-width at every
792 // position. The advance must step a whole UTF-8 scalar, not
793 // one byte -- a byte step lands mid-`│` and panicked ropey's
794 // `byte_slice` on the editor actor thread.
795 let b = Buffer::from_text("a│b");
796 let hits = find_all(&b, &re("|"), &CancellationToken::never()).unwrap();
797 // One zero-width hit per scalar boundary: before 'a', before
798 // '│', before 'b'. (The buffer-end position is not scanned.)
799 assert_eq!(hits.len(), 3);
800 assert_eq!(hits[0].start, p(0, 0));
801 assert_eq!(hits[1].start, p(0, 1));
802 assert_eq!(hits[2].start, p(0, 4));
803 }
804
805 #[test]
806 fn find_forward_from_mid_scalar_offset_does_not_panic() {
807 // Defensive net at the streaming boundary: a `from` that
808 // lands inside a multibyte scalar must snap, not panic.
809 let b = Buffer::from_text("a│bar");
810 let hit = find_forward_in_rope(b.rope(), &re("bar"), 2, &CancellationToken::never())
811 .unwrap()
812 .expect("match");
813 assert_eq!(hit.0, 4);
814 }
815
816 #[test]
817 fn find_backward_window_end_mid_scalar_does_not_panic() {
818 // The backward window end is `from + MAX_MATCH_LEN` clamped
819 // to `total`. With the clamp out of play, that offset is
820 // arbitrary and lands mid-scalar whenever a multibyte glyph
821 // straddles it. Place `│` (3 bytes) across byte 8192.
822 let filler = "a".repeat(MAX_MATCH_LEN - 4); // "bar" + filler ends at 8191
823 let text = format!("bar{filler}│tail");
824 assert!(!text.is_char_boundary(MAX_MATCH_LEN));
825 assert!(text.len() > MAX_MATCH_LEN);
826 let b = Buffer::from_text(&text);
827 let hit = find_backward_in_rope(b.rope(), &re("bar"), 0, &CancellationToken::never())
828 .unwrap()
829 .expect("match");
830 assert_eq!(hit.0, 0);
831 }
832
833 #[test]
834 fn backward_skip_current_via_caller_advance() {
835 // Caller skipping the current match passes from-1.
836 let b = Buffer::from_text("foo bar foo");
837 let hit = find(
838 &b,
839 &re("foo"),
840 p(0, 7),
841 Direction::Backward,
842 &CancellationToken::never(),
843 )
844 .unwrap()
845 .expect("match");
846 assert_eq!(hit.range.start, p(0, 0));
847 }
848
849 // ---- Cancellation ----
850
851 #[test]
852 fn pre_flipped_token_short_circuits_find() {
853 let b = Buffer::from_text("foo bar baz");
854 let token = CancellationToken::new();
855 token.cancel();
856 let result = find(&b, &re("foo"), Position::ZERO, Direction::Forward, &token);
857 assert!(matches!(result, Err(CoreError::Cancelled)));
858 }
859
860 #[test]
861 fn pre_flipped_token_short_circuits_find_all() {
862 let b = Buffer::from_text("foo bar foo baz foo");
863 let token = CancellationToken::new();
864 token.cancel();
865 let result = find_all(&b, &re("foo"), &token);
866 assert!(matches!(result, Err(CoreError::Cancelled)));
867 }
868
869 #[test]
870 fn pre_flipped_token_short_circuits_find_backward() {
871 let b = Buffer::from_text("foo bar foo");
872 let token = CancellationToken::new();
873 token.cancel();
874 let result = find(&b, &re("foo"), p(0, 10), Direction::Backward, &token);
875 assert!(matches!(result, Err(CoreError::Cancelled)));
876 }
877
878 #[test]
879 fn round_down_utf8_boundary_handles_multibyte() {
880 // "café" = 0x63 0x61 0x66 0xC3 0xA9 (5 bytes).
881 let s = "café";
882 let b = s.as_bytes();
883 // Target index 4 lands on the continuation byte 0xA9; round
884 // down to 3 (the start of the 'é' two-byte sequence).
885 assert_eq!(round_down_utf8_boundary(b, 4), 3);
886 // Target index 3 lands on the leading byte of 'é' -- valid.
887 assert_eq!(round_down_utf8_boundary(b, 3), 3);
888 // Past end clamps to len.
889 assert_eq!(round_down_utf8_boundary(b, 99), 5);
890 }
891}