Skip to main content

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}