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(¤t) = 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}