Skip to main content

pdfrum_text/
find.rs

1//! Searching a page's text.
2//!
3//! Searches run over search-facing text ([`TextIndex`](crate::TextIndex)), not
4//! the character stream ([`CharIndex`](crate::CharIndex)).
5//!
6//! The needle is **split, not matched whole**: at spaces, and at every
7//! character outside Latin-1, the Arabic and Cyrillic blocks and General
8//! Punctuation — so every CJK ideograph and every Devanagari letter becomes
9//! its own sub-needle. The sub-needles must appear in order, separated in the
10//! text only by line breaks, spaces or non-breaking spaces. That is what
11//! finds text reflowed across a line break, and what lets a CJK needle match
12//! without the spaces a Latin one would need.
13
14use crate::index::TextIndex;
15use crate::unicode::{is_decimal_digit, lower_string};
16use std::ops::Range;
17
18/// A non-breaking space, which counts as a separator between sub-needles.
19const NON_BREAKING_SPACE: char = '\u{00A0}';
20
21// The soft hyphen the text buffer carries where a word was hyphenated across
22// a line break.
23//
24// `U+00AD`, not the `U+FFFE` noncharacter `cpdf_textpage.cpp:1361`
25// (`AppendChar(0xfffe)`) writes: `pipeline`'s `SOFT_HYPHEN` carries the real
26// character. Dropping it from the haystack is independent of which character
27// stands there — a query for the un-hyphenated word has to match across the
28// break either way.
29const HYPHEN_SENTINEL: char = '\u{00AD}';
30
31/// How a search behaves.
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
33pub struct FindOptions {
34    /// Compare case-sensitively. The default is `false` (case-insensitive).
35    pub match_case: bool,
36    /// Reject a match whose neighbours make it part of a longer word.
37    pub match_whole_word: bool,
38    /// Step forward by one character after a match rather than past it, so
39    /// overlapping matches are all reported.
40    pub consecutive: bool,
41}
42
43/// Whether a character is a standalone searchable unit that the needle is
44/// split around (`IsIgnoreSpaceCharacter`).
45///
46/// The name in the C++ reads backwards: returning true means "split here".
47/// It is false — do not split — for Latin-1 and a handful of script blocks
48/// whose text is written with spaces between words, and true for everything
49/// else.
50#[must_use]
51pub fn splits_needle(ch: char) -> bool {
52    let code = u32::from(ch);
53    // Note `< 255`, not `<= 255`.
54    !(code < 255
55        || (0x0600..=0x06FF).contains(&code)   // Arabic
56        || (0xFE70..=0xFEFF).contains(&code)   // Arabic Presentation Forms-B
57        || (0xFB50..=0xFDFF).contains(&code)   // Arabic Presentation Forms-A
58        || (0x0400..=0x04FF).contains(&code)   // Cyrillic
59        || (0x0500..=0x052F).contains(&code)   // Cyrillic Supplement
60        || (0xA640..=0xA69F).contains(&code)   // Cyrillic Extended-B
61        || (0x2DE0..=0x2DFF).contains(&code)   // Cyrillic Extended-A
62        || code == 0x2113                      // SCRIPT SMALL L
63        || (0x2000..=0x206F).contains(&code)) // General Punctuation
64}
65
66/// Whether a character can separate two sub-needles in the text.
67fn is_separator(ch: char) -> bool {
68    ch == '\n' || ch == ' ' || ch == '\r' || ch == NON_BREAKING_SPACE
69}
70
71/// The `iSubString`-th space-delimited token of a needle
72/// (`ExtractSubString`).
73///
74/// Runs of spaces after a token are skipped, so `"a  b"` has `"b"` at index
75/// one. A trailing space leaves an **empty** token at the next index, and the
76/// index after that has none at all — which is what makes a needle ending in
77/// a space behave differently from one that does not.
78fn sub_string(needle: &[char], index: usize) -> Option<Vec<char>> {
79    let mut at = 0usize;
80    for _ in 0..index {
81        // `wcschr` failing is what ends the walk.
82        let space = needle.get(at..)?.iter().position(|ch| *ch == ' ')?;
83        at += space + 1;
84        while needle.get(at) == Some(&' ') {
85            at += 1;
86        }
87    }
88    let rest = needle.get(at..)?;
89    let end = rest.iter().position(|ch| *ch == ' ').unwrap_or(rest.len());
90    rest.get(..end).map(<[char]>::to_vec)
91}
92
93/// Splits a needle into the sub-needles a match must find in order
94/// (`ExtractFindWhat`).
95///
96/// A needle that is entirely spaces (or empty) is *not* split: it comes back
97/// as one element, which is what makes searching for `" "` mean "find a
98/// space" rather than "find nothing".
99#[must_use]
100pub fn split_needle(needle: &str) -> Vec<String> {
101    let chars: Vec<char> = needle.chars().collect();
102    if chars.iter().all(|ch| *ch == ' ') {
103        return vec![needle.to_owned()];
104    }
105    let mut out: Vec<String> = Vec::new();
106    let mut index = 0usize;
107    // The C++'s `while (true)` ends when `ExtractSubString` runs out; the
108    // bound is belt-and-braces against a needle that somehow never does.
109    while index <= chars.len() {
110        let Some(mut word) = sub_string(&chars, index) else {
111            break;
112        };
113        if word.is_empty() {
114            out.push(String::new());
115            index += 1;
116            continue;
117        }
118        let mut pos = 0usize;
119        while pos < word.len() {
120            let Some(&current) = word.get(pos) else { break };
121            if splits_needle(current) {
122                // A right single quotation mark inside a word is an
123                // apostrophe, not a split point.
124                if pos > 0 && current == '\u{2019}' {
125                    pos += 1;
126                    continue;
127                }
128                if pos > 0 {
129                    out.push(word.get(..pos).unwrap_or_default().iter().collect());
130                }
131                out.push(current.to_string());
132                if pos == word.len() - 1 {
133                    word.clear();
134                    break;
135                }
136                word = word.get(pos + 1..).unwrap_or_default().to_vec();
137                pos = 0;
138                continue;
139            }
140            pos += 1;
141        }
142        if !word.is_empty() {
143            out.push(word.iter().collect());
144        }
145        index += 1;
146    }
147    out
148}
149
150/// Whether a match's neighbours leave it standing as a whole word
151/// (`IsMatchWholeWord`).
152///
153/// Two overlapping tests, both transcribed: the first uses **exclusive**
154/// bounds, so `'A'`, `'a'`, `'z'`, `U+FB00` and `U+FB06` pass it and `'Z'`
155/// does not; the second then rejects any ASCII letter properly. The redundancy
156/// is harmless and the exclusive ligature band appears only in the first.
157#[must_use]
158pub fn is_whole_word(text: &[char], start: usize, end: usize) -> bool {
159    if start > end {
160        return false;
161    }
162    let count = end - start + 1;
163    if count == 1
164        && text
165            .get(start)
166            .copied()
167            .is_some_and(|ch| u32::from(ch) > 255)
168    {
169        return true;
170    }
171    let at = |index: usize| -> u32 { text.get(index).copied().map_or(0, u32::from) };
172    let left = if start >= 1 { at(start - 1) } else { 0 };
173    let right = if start + count < text.len() {
174        at(start + count)
175    } else {
176        0
177    };
178    let letterish = |ch: u32| {
179        (ch > u32::from(b'A') && ch < u32::from(b'a'))
180            || (ch > u32::from(b'a') && ch < u32::from(b'z'))
181            || (ch > 0xFB00 && ch < 0xFB06)
182            || is_decimal_digit(ch)
183    };
184    if letterish(left) || letterish(right) {
185        return false;
186    }
187    let outside_ascii_letters = |ch: u32| {
188        (u32::from(b'A') > ch || ch > u32::from(b'Z'))
189            && (u32::from(b'a') > ch || ch > u32::from(b'z'))
190    };
191    if !(outside_ascii_letters(left) && outside_ascii_letters(right)) {
192        return false;
193    }
194    if is_decimal_digit(left) && is_decimal_digit(at(start)) {
195        return false;
196    }
197    if is_decimal_digit(right) && is_decimal_digit(at(end)) {
198        return false;
199    }
200    true
201}
202
203/// A search in progress over one page's text.
204///
205/// Yields text-offset ranges. `Iterator` rather than the C++'s
206/// find-next/find-previous pair: "previous" is a reverse walk over the same
207/// sequence, so the second engine the C++ constructs is unnecessary.
208#[derive(Debug, Clone)]
209pub struct Search<'a> {
210    /// The haystack, case-folded when the search is insensitive, with the
211    /// soft-hyphen sentinels removed — see [`search`].
212    haystack: Vec<char>,
213    /// `[oracle-bug]` For each haystack index, the text index it came from.
214    /// Empty when nothing was removed, in which case the two spaces coincide.
215    origins: Vec<usize>,
216    /// The sub-needles, case-folded the same way.
217    needles: Vec<Vec<char>>,
218    options: FindOptions,
219    /// Where the next scan starts, or `None` when the search is finished.
220    next_start: Option<usize>,
221    marker: std::marker::PhantomData<&'a ()>,
222}
223
224/// Builds a search over `text`.
225///
226/// A word split across a line break by a soft hyphen is matched joined, and
227/// the yielded ranges are still offsets into `text`.
228// `[oracle-bug]` **A word split across a line break is searched joined.**
229// `cpdf_textpage.cpp:1360-1361` writes `U+FFFE` into the text buffer at a
230// soft hyphen, and `cpdf_textpagefind.cpp:209-211`/`:262` search that buffer
231// with a plain `Find`, so `"note-\nbook"` can never match `"notebook"`
232// (`crbug.com/431824298`). What makes it a bug rather than a trade-off is the
233// **asymmetry**: `cpdf_linkextract.cpp:154-155` repairs the very same
234// sentinel (`Replace(L"\xfffe", L"-")`) for link detection and find does not.
235// pdf.js joins across the break and keeps a reversible index map so the
236// caller still gets offsets into the original text
237// (`pdf_find_controller.js:131`, `:290-307`, whose `p5.slice(0, -2)` drops
238// the hyphen *and* the newline). The same shape is used here: the sentinel is
239// dropped from the haystack and `origins` maps every haystack index back to
240// its text index, so the yielded ranges are still text offsets.
241#[must_use]
242pub fn search<'a>(text: &str, needle: &str, options: FindOptions) -> Search<'a> {
243    let fold = |value: &str| -> String {
244        if options.match_case {
245            value.to_owned()
246        } else {
247            lower_string(value)
248        }
249    };
250    let folded: Vec<char> = fold(text).chars().collect();
251    let mut haystack: Vec<char> = Vec::with_capacity(folded.len());
252    let mut origins: Vec<usize> = Vec::with_capacity(folded.len());
253    let mut dropped = false;
254    for (at, &ch) in folded.iter().enumerate() {
255        if ch == HYPHEN_SENTINEL {
256            dropped = true;
257            continue;
258        }
259        haystack.push(ch);
260        origins.push(at);
261    }
262    if !dropped {
263        origins.clear();
264    }
265    let needles: Vec<Vec<char>> = split_needle(&fold(needle))
266        .into_iter()
267        .map(|word| word.chars().collect())
268        .collect();
269    Search {
270        // An empty haystack never starts, which the C++ expresses by leaving
271        // both cursors unset.
272        next_start: (!haystack.is_empty()).then_some(0),
273        haystack,
274        origins,
275        needles,
276        options,
277        marker: std::marker::PhantomData,
278    }
279}
280
281impl Iterator for Search<'_> {
282    type Item = Range<TextIndex>;
283
284    fn next(&mut self) -> Option<Range<TextIndex>> {
285        let start = self.next_start?;
286        let (result_start, result_end) = self.scan(start)?;
287        self.next_start = Some(if self.options.consecutive {
288            result_start + 1
289        } else {
290            result_end + 1
291        });
292        // `[oracle-bug]` Back into text offsets. The end is inclusive here, so
293        // the exclusive bound is its origin plus one — which is what keeps a
294        // match that *spans* a dropped sentinel covering it in the text.
295        Some(TextIndex::new(self.origin(result_start))..TextIndex::new(self.origin(result_end) + 1))
296    }
297}
298
299impl Search<'_> {
300    /// The text offset a haystack index came from.
301    ///
302    /// The identity when nothing was dropped, which is every page without a
303    /// hyphenated line break.
304    fn origin(&self, index: usize) -> usize {
305        self.origins.get(index).copied().unwrap_or(index)
306    }
307
308    /// One scan, from `from`, returning the inclusive `(start, end)` of a
309    /// match (`FindNext`).
310    ///
311    /// The restart is the awkward part: when a sub-needle matches in the
312    /// wrong place the C++ sets its loop counter to `-1` so the increment
313    /// makes it zero, restarting the whole walk from a start position that has
314    /// advanced. Termination rests entirely on that advance, which happens
315    /// because the first sub-needle has already matched once and set the
316    /// result start. Reproduced with an explicit loop plus the same advance
317    /// rule, and bounded so a needle whose first element is empty and whose
318    /// second never matches cannot spin.
319    fn scan(&mut self, from: usize) -> Option<(usize, usize)> {
320        let length = self.haystack.len();
321        if self.haystack.is_empty() || self.needles.is_empty() || from >= length {
322            self.next_start = None;
323            return None;
324        }
325        let mut start = from;
326        let mut result_pos = 0usize;
327        let mut result_start = 0usize;
328        let mut space_start = false;
329        let mut word = 0usize;
330        // Every restart advances `start`, so `length + 1` restarts is more
331        // than the walk can possibly need.
332        let mut restarts_left = length + 1;
333
334        while word < self.needles.len() {
335            let Some(needle) = self.needles.get(word) else {
336                break;
337            };
338            if needle.is_empty() {
339                if word == self.needles.len() - 1 {
340                    // A trailing empty sub-needle matches one separator.
341                    let Some(&ch) = self.haystack.get(start) else {
342                        self.next_start = None;
343                        return None;
344                    };
345                    if is_separator(ch) {
346                        result_pos = start + 1;
347                        break;
348                    }
349                    // Restart, which cannot make progress here — the start
350                    // has not moved — so the bound is what ends it.
351                    restarts_left = restarts_left.checked_sub(1)?;
352                    word = 0;
353                    continue;
354                }
355                if word == 0 {
356                    space_start = true;
357                }
358                word += 1;
359                continue;
360            }
361            let Some(found) = find_from(&self.haystack, needle, start) else {
362                self.next_start = None;
363                return None;
364            };
365            result_pos = found;
366            let end_index = found + needle.len() - 1;
367            if word == 0 {
368                result_start = found;
369            }
370            let mut matched = true;
371            if word != 0 && !space_start {
372                let current = needle.first().copied().unwrap_or('\0');
373                let last = self
374                    .needles
375                    .get(word - 1)
376                    .and_then(|previous| previous.last().copied())
377                    .unwrap_or('\0');
378                // Two sub-needles butted together are only a match when one
379                // of the joining characters is a standalone unit — which is
380                // what lets CJK match without spaces and makes Latin need one.
381                if start == found && !(splits_needle(last) || splits_needle(current)) {
382                    matched = false;
383                }
384                for offset in start..found {
385                    if !self.haystack.get(offset).copied().is_some_and(is_separator) {
386                        matched = false;
387                        break;
388                    }
389                }
390            } else if space_start && found > 0 {
391                let before = self.haystack.get(found - 1).copied().unwrap_or('\0');
392                if is_separator(before) {
393                    // The leading empty sub-needle consumed the separator, so
394                    // the match starts one earlier.
395                    result_start = found - 1;
396                } else {
397                    matched = false;
398                    result_start = found;
399                }
400            }
401            if self.options.match_whole_word && matched {
402                matched = is_whole_word(&self.haystack, found, end_index);
403            }
404            if matched {
405                start = end_index + 1;
406                word += 1;
407            } else {
408                restarts_left = restarts_left.checked_sub(1)?;
409                let index = usize::from(space_start);
410                let advance = self.needles.get(index).map_or(0, Vec::len);
411                start = result_start + advance;
412                if start >= length {
413                    self.next_start = None;
414                    return None;
415                }
416                word = 0;
417            }
418        }
419
420        let last_len = self.needles.last().map_or(0, Vec::len);
421        // The end is derived from the *last* scan position plus the last
422        // sub-needle's length, which on the trailing-empty-needle path makes
423        // the match one character long.
424        let result_end = result_pos + last_len;
425        let result_end = result_end.checked_sub(1)?;
426        Some((result_start, result_end))
427    }
428}
429
430/// The first occurrence of `needle` in `haystack` at or after `from`.
431fn find_from(haystack: &[char], needle: &[char], from: usize) -> Option<usize> {
432    if needle.is_empty() || from > haystack.len() {
433        return None;
434    }
435    let last = haystack.len().checked_sub(needle.len())?;
436    (from..=last).find(|start| haystack.get(*start..start + needle.len()) == Some(needle))
437}
438
439#[cfg(test)]
440mod tests {
441    // Test fixtures quote the oracle's own vectors, compare floats exactly
442    // where the behaviour being pinned is exact, and index arrays whose
443    // length the fixture itself fixes.
444    #![allow(
445        clippy::float_cmp,
446        clippy::indexing_slicing,
447        clippy::unreadable_literal,
448        clippy::cast_precision_loss,
449        clippy::cast_possible_truncation,
450        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
451    )]
452
453    use super::*;
454
455    /// The matches as plain numbers, so a fixture reads as offsets rather
456    /// than as constructor calls. The type the iterator yields is
457    /// [`TextIndex`]; what these tests pin is the arithmetic inside it.
458    fn ranges(text: &str, needle: &str, options: FindOptions) -> Vec<Range<usize>> {
459        search(text, needle, options)
460            .map(|hit| hit.start.get()..hit.end.get())
461            .collect()
462    }
463
464    const HELLO: &str = "Hello, world!\r\nGoodbye, world!";
465
466    // `cpdf_textpage.cpp:1360-1361` writes `U+FFFE` into the text buffer at a
467    // soft hyphen and `cpdf_textpagefind.cpp:262` searches that buffer
468    // verbatim, so a word split across a line break can never be found
469    // (crbug.com/431824298). We drop the hyphen from the haystack and map
470    // back, so the word is found and the range is still a text offset.
471    //
472    // The character dropped is `U+00AD` rather than `U+FFFE`; what matters is
473    // that *something* stands between the two halves of the word and is not
474    // itself part of either.
475    #[test]
476    fn a_word_split_across_a_line_break_is_found_joined() {
477        // "a note-\nbook here", as the pipeline writes it: the hyphen and the
478        // break collapse to the one soft hyphen.
479        let text = "a note\u{00AD}book here";
480        let hits: Vec<_> = search(text, "notebook", FindOptions::default()).collect();
481        assert_eq!(hits.len(), 1, "the joined word is found");
482
483        // The range is a *text* offset, and it spans the soft hyphen, so
484        // slicing the original text by it recovers the split spelling.
485        let chars: Vec<char> = text.chars().collect();
486        let hit = hits[0].start.get()..hits[0].end.get();
487        let slice: String = chars[hit.clone()].iter().collect();
488        assert_eq!(slice, "note\u{00AD}book");
489        assert_eq!(hit, 2..11);
490
491        // And the soft hyphen is not itself a space: dropping it must not
492        // splice two words into a spelling the page does not contain.
493        assert_eq!(
494            search(text, "note book", FindOptions::default()).count(),
495            0,
496            "the sentinel is not a space"
497        );
498    }
499
500    #[test]
501    fn splitting_is_by_script_not_by_alphabet() {
502        // Latin-1 and the named blocks stay whole.
503        assert!(!splits_needle('a'));
504        assert!(!splits_needle('\u{00FE}'));
505        assert!(!splits_needle('\u{0410}')); // Cyrillic
506        assert!(!splits_needle('\u{0627}')); // Arabic
507        assert!(!splits_needle('\u{2019}')); // General Punctuation
508        assert!(!splits_needle('\u{2113}')); // SCRIPT SMALL L
509        // Everything else is its own unit.
510        assert!(splits_needle('\u{4E00}')); // CJK
511        assert!(splits_needle('\u{AC00}')); // Hangul
512        assert!(splits_needle('\u{0905}')); // Devanagari
513        // The bound is `< 255`, so U+00FF itself splits.
514        assert!(splits_needle('\u{00FF}'));
515    }
516
517    #[test]
518    fn an_all_space_needle_is_not_split() {
519        assert_eq!(split_needle(" "), [" "]);
520        assert_eq!(split_needle("   "), ["   "]);
521        assert_eq!(split_needle(""), [""]);
522    }
523
524    #[test]
525    fn a_needle_splits_at_spaces_and_at_standalone_units() {
526        assert_eq!(split_needle("ab cd"), ["ab", "cd"]);
527        // Runs of spaces are skipped, not turned into empty tokens.
528        assert_eq!(split_needle("ab  cd"), ["ab", "cd"]);
529        // A CJK character splits the token around it.
530        assert_eq!(split_needle("a\u{4E00}b"), ["a", "\u{4E00}", "b"]);
531        assert_eq!(split_needle("\u{4E00}\u{4E8C}"), ["\u{4E00}", "\u{4E8C}"]);
532        // An apostrophe inside a word is not a split point.
533        assert_eq!(split_needle("don\u{2019}t"), ["don\u{2019}t"]);
534    }
535
536    #[test]
537    fn a_trailing_space_leaves_an_empty_sub_needle() {
538        assert_eq!(split_needle("ld! "), ["ld!", ""]);
539        // And a leading one puts the empty element first.
540        assert_eq!(split_needle(" Good"), ["", "Good"]);
541    }
542
543    #[test]
544    fn substring_extraction_walks_space_delimited_tokens() {
545        let chars: Vec<char> = "a b".chars().collect();
546        assert_eq!(sub_string(&chars, 0), Some(vec!['a']));
547        assert_eq!(sub_string(&chars, 1), Some(vec!['b']));
548        assert_eq!(sub_string(&chars, 2), None);
549        // A run of spaces is skipped as one separator.
550        let chars: Vec<char> = "a  b".chars().collect();
551        assert_eq!(sub_string(&chars, 1), Some(vec!['b']));
552        // A trailing space leaves an empty token, then nothing.
553        let chars: Vec<char> = "a ".chars().collect();
554        assert_eq!(sub_string(&chars, 1), Some(vec![]));
555        assert_eq!(sub_string(&chars, 2), None);
556    }
557
558    #[test]
559    fn searching_finds_every_occurrence() {
560        assert_eq!(ranges(HELLO, "nope", FindOptions::default()), []);
561        assert_eq!(
562            ranges(HELLO, "world", FindOptions::default()),
563            [7..12, 24..29]
564        );
565    }
566
567    #[test]
568    fn the_default_is_case_insensitive() {
569        assert_eq!(
570            ranges(HELLO, "WORLD", FindOptions::default()),
571            [7..12, 24..29]
572        );
573        let cased = FindOptions {
574            match_case: true,
575            ..FindOptions::default()
576        };
577        assert_eq!(ranges(HELLO, "WORLD", cased), []);
578        assert_eq!(ranges(HELLO, "world", cased), [7..12, 24..29]);
579    }
580
581    #[test]
582    fn whole_word_rejects_a_substring_match() {
583        let whole = FindOptions {
584            match_whole_word: true,
585            ..FindOptions::default()
586        };
587        // "orld" matches as a substring but not as a word.
588        assert_eq!(
589            ranges(HELLO, "orld", FindOptions::default()),
590            [8..12, 25..29]
591        );
592        assert_eq!(ranges(HELLO, "orld", whole), []);
593        assert_eq!(ranges(HELLO, "world", whole), [7..12, 24..29]);
594    }
595
596    #[test]
597    fn consecutive_reports_overlapping_matches() {
598        let text = "aaaaaaaaaa";
599        assert_eq!(ranges(text, "aaaa", FindOptions::default()), [0..4, 4..8]);
600        let consecutive = FindOptions {
601            consecutive: true,
602            ..FindOptions::default()
603        };
604        assert_eq!(
605            ranges(text, "aaaa", consecutive),
606            [0..4, 1..5, 2..6, 3..7, 4..8, 5..9, 6..10]
607        );
608    }
609
610    #[test]
611    fn a_needle_spanning_a_line_break_matches_through_it() {
612        // A single space in the needle spans the two-character CRLF run.
613        assert_eq!(ranges(HELLO, "ld! G", FindOptions::default()), vec![10..16]);
614    }
615
616    #[test]
617    fn a_leading_space_in_the_needle_matches_the_separator_before_the_word() {
618        // The match starts on the '\n' at 14, not on the 'G' at 15.
619        assert_eq!(ranges(HELLO, " Good", FindOptions::default()), vec![14..19]);
620    }
621
622    #[test]
623    fn a_trailing_space_in_the_needle_matches_the_separator_after_the_word() {
624        // "ld! " matches "ld!" plus the '\r' at 13.
625        assert_eq!(ranges(HELLO, "ld! ", FindOptions::default()), vec![10..14]);
626    }
627
628    #[test]
629    fn searching_an_empty_page_finds_nothing() {
630        assert_eq!(ranges("", "anything", FindOptions::default()), []);
631        assert_eq!(ranges("text", "", FindOptions::default()), []);
632    }
633
634    #[test]
635    fn whole_word_boundaries_use_the_two_overlapping_tests() {
636        // An ASCII letter on either side is caught by the *second* test even
637        // when the first lets it through: 'a' passes the exclusive band and
638        // is then rejected outright.
639        let text: Vec<char> = "a-b".chars().collect();
640        assert!(!is_whole_word(&text, 1, 1));
641        // Non-letter neighbours leave the match standing.
642        let spaced: Vec<char> = " - ".chars().collect();
643        assert!(is_whole_word(&spaced, 1, 1));
644        // A digit beside a digit is not.
645        let digits: Vec<char> = "12".chars().collect();
646        assert!(!is_whole_word(&digits, 1, 1));
647        // 'Z' is caught only by the second test, and still rejects.
648        let capital: Vec<char> = "Zx".chars().collect();
649        assert!(!is_whole_word(&capital, 1, 1));
650        // An inverted range never matches.
651        assert!(!is_whole_word(&digits, 1, 0));
652        // A single non-Latin-1 character is a whole word whatever surrounds it.
653        let cjk: Vec<char> = "a\u{4E00}b".chars().collect();
654        assert!(is_whole_word(&cjk, 1, 1));
655    }
656
657    #[test]
658    fn a_restarting_search_terminates() {
659        // A needle whose first sub-needle is empty and whose second never
660        // matches must stop rather than spin.
661        let found = ranges("aaaa", " zzz", FindOptions::default());
662        assert!(found.is_empty());
663        // And one whose only sub-needle is empty against a haystack with no
664        // separator at all.
665        let found = ranges("aaaa", " ", FindOptions::default());
666        assert!(found.is_empty(), "{found:?}");
667    }
668}