Skip to main content

provenant/license_detection/
tokenize.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Text tokenization and normalization.
8//!
9//! Tokenization converts text into a sequence of tokens that can be matched
10//! against license rules. This module implements ScanCode-compatible tokenization.
11
12use crate::license_detection::index::dictionary::{QueryToken, TokenDictionary};
13use regex::Regex;
14use std::collections::HashSet;
15use std::ops::Range;
16use std::sync::LazyLock;
17
18const REQUIRED_PHRASE_OPEN: &str = "{{";
19const REQUIRED_PHRASE_CLOSE: &str = "}}";
20
21/// Common words that are ignored from matching such as HTML tags, XML entities, etc.
22///
23/// This is the Rust equivalent of the Python STOPWORDS frozenset from
24/// reference/scancode-toolkit/src/licensedcode/stopwords.py
25pub(crate) static STOPWORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
26    let mut set = HashSet::new();
27
28    // common XML character references as &quot;
29    for &word in &["amp", "apos", "gt", "lt", "nbsp", "quot"] {
30        set.insert(word);
31    }
32
33    // common html tags as <a href=https://link ...> dfsdfsdf</a>
34    for &word in &[
35        "a",
36        "abbr",
37        "alt",
38        "blockquote",
39        "body",
40        "br",
41        "class",
42        "div",
43        "em",
44        "h1",
45        "h2",
46        "h3",
47        "h4",
48        "h5",
49        "hr",
50        "href",
51        "img",
52        "li",
53        "ol",
54        "p",
55        "pre",
56        "rel",
57        "script",
58        "span",
59        "src",
60        "td",
61        "th",
62        "tr",
63        "ul",
64    ] {
65        set.insert(word);
66    }
67
68    // comment line markers
69    set.insert("rem"); // batch files
70    set.insert("dnl"); // autotools
71
72    // doc book tags as <para>
73    set.insert("para");
74    set.insert("ulink");
75
76    // Some HTML punctuations and entities all as &emdash;
77    for &word in &[
78        "bdquo", "bull", "bullet", "colon", "comma", "emdash", "emsp", "ensp", "ge", "hairsp",
79        "ldquo", "ldquor", "le", "lpar", "lsaquo", "lsquo", "lsquor", "mdash", "ndash", "numsp",
80        "period", "puncsp", "raquo", "rdquo", "rdquor", "rpar", "rsaquo", "rsquo", "rsquor",
81        "sbquo", "semi", "thinsp", "tilde",
82    ] {
83        set.insert(word);
84    }
85
86    // some xml char entities
87    set.insert("x3c");
88    set.insert("x3e");
89
90    // seen in many CSS
91    for &word in &[
92        "lists", "side", "nav", "height", "auto", "border", "padding", "width",
93    ] {
94        set.insert(word);
95    }
96
97    // seen in Perl PODs
98    set.insert("head1");
99    set.insert("head2");
100    set.insert("head3");
101
102    // common in C literals
103    set.insert("printf");
104
105    // common in shell
106    set.insert("echo");
107
108    set
109});
110
111/// Splits on whitespace and punctuation: keep only characters and numbers and + when in the middle or end of a word.
112///
113/// The pattern is equivalent to Python's: `[^_\W]+\+?[^_\W]*`
114/// - `[^_\W]+` - one or more characters that are NOT underscore and NOT non-word (i.e., alphanumeric including Unicode)
115/// - `\+?` - optional plus sign (important for license names like "GPL2+")
116/// - `[^_\W]*` - zero or more alphanumeric characters (including Unicode)
117///
118/// This matches word-like sequences while preserving trailing `+` characters.
119/// Uses Unicode-aware matching to match Python's `re.UNICODE` behavior.
120static QUERY_PATTERN: LazyLock<Regex> =
121    LazyLock::new(|| Regex::new(r"[^_\W]+\+?[^_\W]*").expect("Invalid regex pattern"));
122
123/// Tokenizes text to match index rules and queries.
124///
125/// Splits text into tokens using regex pattern, normalizes each token (lowercase),
126/// and filters out empty strings and stopwords.
127///
128/// # Returns
129/// A vector of token strings.
130pub fn tokenize(text: &str) -> Vec<String> {
131    if text.is_empty() {
132        return Vec::new();
133    }
134
135    let mut tokens = Vec::new();
136    let lowercase_text = text.to_lowercase();
137
138    for cap in QUERY_PATTERN.find_iter(&lowercase_text) {
139        let token = cap.as_str();
140
141        // Filter out empty strings and stopwords
142        if !token.is_empty() && !STOPWORDS.contains(token) {
143            tokens.push(token.to_string());
144        }
145    }
146
147    tokens
148}
149
150/// Tokenizes text without filtering stopwords.
151///
152/// This is used for query text where stopwords are handled at a later stage.
153///
154/// # Returns
155/// A vector of token strings.
156pub fn tokenize_without_stopwords(text: &str) -> Vec<String> {
157    if text.is_empty() {
158        return Vec::new();
159    }
160
161    let mut tokens = Vec::new();
162    let lowercase_text = text.to_lowercase();
163
164    for cap in QUERY_PATTERN.find_iter(&lowercase_text) {
165        let token = cap.as_str();
166
167        // Filter out empty strings but keep stopwords
168        if !token.is_empty() {
169            tokens.push(token.to_string());
170        }
171    }
172
173    tokens
174}
175
176/// Tokenizes text and returns QueryTokens directly, avoiding string allocation.
177///
178/// This is the primary tokenization function for query processing.
179/// Tokens are classified against the dictionary immediately:
180/// - Known tokens → QueryToken::Known(KnownToken)
181/// - Unknown tokens → QueryToken::Unknown
182/// - Stopwords → QueryToken::Stopword
183///
184/// # Returns
185/// A vector of QueryTokens (no string allocation).
186pub fn tokenize_as_ids(text: &str, dictionary: &TokenDictionary) -> Vec<QueryToken> {
187    if text.is_empty() {
188        return Vec::new();
189    }
190
191    let mut tokens = Vec::new();
192    let stopwords_set = &*STOPWORDS;
193
194    let lowercase_text = text.to_lowercase();
195
196    for cap in QUERY_PATTERN.find_iter(&lowercase_text) {
197        let token = cap.as_str();
198        if token.is_empty() {
199            continue;
200        }
201
202        if stopwords_set.contains(token) {
203            tokens.push(QueryToken::Stopword);
204        } else {
205            tokens.push(dictionary.classify_query_token(token));
206        }
207    }
208
209    tokens
210}
211
212/// Count tokens in text without allocating strings.
213///
214/// Used for line length detection without memory overhead.
215pub fn count_tokens(text: &str) -> usize {
216    if text.is_empty() {
217        return 0;
218    }
219
220    let lowercase_text = text.to_lowercase();
221    let stopwords_set = &*STOPWORDS;
222
223    QUERY_PATTERN
224        .find_iter(&lowercase_text)
225        .filter(|m| !m.as_str().is_empty() && !stopwords_set.contains(m.as_str()))
226        .count()
227}
228
229/// Parse {{...}} required phrase markers from rule text.
230///
231/// Returns list of token position ranges for required phrases.
232/// The spans represent the positions (after tokenization) of tokens
233/// that MUST be matched for the rule to be considered valid.
234///
235/// # Arguments
236/// * `text` - The rule text containing optional {{...}} markers
237///
238/// # Returns
239/// A vector of Range<usize> representing token positions for each required phrase.
240/// Empty vector if no valid required phrases found.
241///
242/// Example: `This is {{enclosed}} in braces` yields `vec![2..3]`.
243///
244/// Based on Python: `get_existing_required_phrase_spans()` in tokenize.py:122-174
245pub fn parse_required_phrase_spans(text: &str) -> Vec<Range<usize>> {
246    let mut spans = Vec::new();
247    let mut in_required_phrase = false;
248    let mut current_phrase_positions: Vec<usize> = Vec::new();
249    let mut ipos = 0usize;
250
251    for token in required_phrase_tokenizer(text) {
252        if token == REQUIRED_PHRASE_OPEN {
253            if in_required_phrase {
254                log::warn!(
255                    "Invalid rule with nested required phrase {{ {{ braces: {}",
256                    text
257                );
258                return Vec::new();
259            }
260            in_required_phrase = true;
261        } else if token == REQUIRED_PHRASE_CLOSE {
262            if in_required_phrase {
263                if !current_phrase_positions.is_empty() {
264                    let min_pos = *current_phrase_positions.iter().min().unwrap_or(&0);
265                    let max_pos = *current_phrase_positions.iter().max().unwrap_or(&0);
266                    spans.push(min_pos..max_pos + 1);
267                    current_phrase_positions.clear();
268                } else {
269                    log::warn!(
270                        "Invalid rule with empty required phrase {{}} braces: {}",
271                        text
272                    );
273                    return Vec::new();
274                }
275                in_required_phrase = false;
276            } else {
277                log::warn!(
278                    "Invalid rule with dangling required phrase missing closing braces: {}",
279                    text
280                );
281                return Vec::new();
282            }
283        } else {
284            if in_required_phrase {
285                current_phrase_positions.push(ipos);
286            }
287            ipos += 1;
288        }
289    }
290
291    if !current_phrase_positions.is_empty() || in_required_phrase {
292        log::warn!(
293            "Invalid rule with dangling required phrase missing final closing braces: {}",
294            text
295        );
296        return Vec::new();
297    }
298
299    spans
300}
301
302/// Tokenizer for parsing required phrase markers.
303///
304/// Yields tokens including "{{" and "}}" markers.
305/// Similar to the required_phrase_tokenizer generator in Python.
306fn required_phrase_tokenizer(text: &str) -> RequiredPhraseTokenIter {
307    let lowercase_text = text.to_lowercase();
308    let tokens: Vec<TokenKind> = REQUIRED_PHRASE_PATTERN
309        .find_iter(&lowercase_text)
310        .filter_map(|m| {
311            let token = m.as_str();
312            if token == REQUIRED_PHRASE_OPEN {
313                Some(TokenKind::Open)
314            } else if token == REQUIRED_PHRASE_CLOSE {
315                Some(TokenKind::Close)
316            } else if !token.is_empty() && !STOPWORDS.contains(token) {
317                Some(TokenKind::Word)
318            } else {
319                None
320            }
321        })
322        .collect();
323    RequiredPhraseTokenIter { tokens, pos: 0 }
324}
325
326#[derive(Clone, Copy, PartialEq)]
327enum TokenKind {
328    Open,
329    Close,
330    Word,
331}
332
333struct RequiredPhraseTokenIter {
334    tokens: Vec<TokenKind>,
335    pos: usize,
336}
337
338impl Iterator for RequiredPhraseTokenIter {
339    type Item = &'static str;
340
341    fn next(&mut self) -> Option<Self::Item> {
342        if self.pos >= self.tokens.len() {
343            return None;
344        }
345        let token = self.tokens[self.pos];
346        self.pos += 1;
347        Some(match token {
348            TokenKind::Open => REQUIRED_PHRASE_OPEN,
349            TokenKind::Close => REQUIRED_PHRASE_CLOSE,
350            TokenKind::Word => "word",
351        })
352    }
353}
354
355/// Pattern for matching words and braces in required phrase tokenizer.
356/// Equivalent to Python's: `(?:[^_\W]+\+?[^_\W]*|\{\{|\}\})`
357static REQUIRED_PHRASE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
358    Regex::new(r"(?:[^_\W]+\+?[^_\W]*|\{\{|\}\})").expect("Invalid required phrase pattern")
359});
360
361/// Tokenize text and track stopwords by position.
362///
363/// Returns (tokens, stopwords_by_pos) where:
364/// - tokens: vector of token strings
365/// - stopwords_by_pos: mapping from token position to count of stopwords after that position
366///
367/// Based on Python: `index_tokenizer_with_stopwords()` in tokenize.py:247-306
368pub fn tokenize_with_stopwords(
369    text: &str,
370) -> (Vec<String>, std::collections::HashMap<Option<usize>, usize>) {
371    if text.is_empty() {
372        return (Vec::new(), std::collections::HashMap::new());
373    }
374
375    let mut tokens = Vec::new();
376    let mut stopwords_by_pos = std::collections::HashMap::new();
377
378    let mut pos: Option<usize> = None;
379    let lowercase_text = text.to_lowercase();
380
381    for cap in QUERY_PATTERN.find_iter(&lowercase_text) {
382        let token = cap.as_str();
383        if token.is_empty() {
384            continue;
385        }
386
387        if STOPWORDS.contains(token) {
388            *stopwords_by_pos.entry(pos).or_insert(0) += 1;
389        } else {
390            pos = Some(pos.map_or(0, |p| p + 1));
391            tokens.push(token.to_string());
392        }
393    }
394
395    (tokens, stopwords_by_pos)
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn test_tokenize_empty() {
404        let result = tokenize("");
405        assert!(result.is_empty());
406    }
407
408    #[test]
409    fn test_tokenize_simple() {
410        let result = tokenize("Hello World");
411        assert_eq!(result, vec!["hello", "world"]);
412    }
413
414    #[test]
415    fn test_tokenize_with_punctuation() {
416        let result = tokenize("Hello, World! This is a test.");
417        // Note: 'a' is filtered because it's in STOPWORDS (it's an HTML tag)
418        assert_eq!(result, vec!["hello", "world", "this", "is", "test"]);
419    }
420
421    #[test]
422    fn test_tokenize_with_spaces() {
423        let result = tokenize("some Text with   spAces!");
424        assert_eq!(result, vec!["some", "text", "with", "spaces"]);
425    }
426
427    #[test]
428    fn test_tokenize_with_plus() {
429        let result = tokenize("GPL2+ and GPL3");
430        assert_eq!(result, vec!["gpl2+", "and", "gpl3"]);
431    }
432
433    #[test]
434    fn test_tokenize_filters_stopwords() {
435        let result = tokenize("Hello div World p");
436        assert_eq!(result, vec!["hello", "world"]);
437    }
438
439    #[test]
440    fn test_tokenize_with_special_chars() {
441        let result = tokenize("special+-_!@ chars");
442        // Based on Python: ['special+', 'chars']
443        assert_eq!(result, vec!["special+", "chars"]);
444    }
445
446    #[test]
447    fn test_tokenize_with_underscores() {
448        let result = tokenize("hello_world foo_bar");
449        assert_eq!(result, vec!["hello", "world", "foo", "bar"]);
450    }
451
452    #[test]
453    fn test_tokenize_with_numbers() {
454        let result = tokenize("version 2.0 and 3.0");
455        assert_eq!(result, vec!["version", "2", "0", "and", "3", "0"]);
456    }
457
458    #[test]
459    fn test_tokenize_without_stopwords_keeps_html_tags() {
460        let result = tokenize_without_stopwords("Hello div World p");
461        assert_eq!(result, vec!["hello", "div", "world", "p"]);
462    }
463
464    #[test]
465    fn test_tokenize_without_stopwords_empty() {
466        let result = tokenize_without_stopwords("");
467        assert!(result.is_empty());
468    }
469
470    #[test]
471    fn test_tokenization_with_plus_in_middle() {
472        let result = tokenize("C++ and GPL+");
473        assert_eq!(result, vec!["c+", "and", "gpl+"]);
474    }
475
476    #[test]
477    fn test_tokenization_braces() {
478        let result = tokenize("{{Hi}}some {{}}Text with{{noth+-_!@ing}}   {{junk}}spAces!");
479        assert_eq!(
480            result,
481            vec![
482                "hi", "some", "text", "with", "noth+", "ing", "junk", "spaces"
483            ]
484        );
485    }
486
487    #[test]
488    fn test_tokenize_with_ampersand() {
489        let result = tokenize("some &quot< markup &gt\"");
490        assert_eq!(result, vec!["some", "markup"]);
491    }
492
493    #[test]
494    fn test_query_tokenizer_brace_case() {
495        let result = tokenize("{{}some }}Text with   spAces! + _ -");
496        assert_eq!(result, vec!["some", "text", "with", "spaces"]);
497    }
498
499    #[test]
500    fn test_tokenize_unicode_characters() {
501        // With Unicode pattern [^_\W], we match Unicode letters like Python's re.UNICODE
502        let result = tokenize("hello 世界 mir");
503        assert_eq!(result, vec!["hello", "世界", "mir"]);
504    }
505
506    #[test]
507    fn test_tokenize_only_special_chars() {
508        let result = tokenize("!@#$%^&*()");
509        assert!(result.is_empty());
510    }
511
512    #[test]
513    fn test_tokenize_only_punctuation() {
514        let result = tokenize(".,;:!?-_=+[]{}()");
515        assert!(result.is_empty());
516    }
517
518    #[test]
519    fn test_tokenize_only_stopwords() {
520        let result = tokenize("div p a br");
521        assert!(result.is_empty());
522    }
523
524    #[test]
525    fn test_tokenize_mixed_stopwords_and_words() {
526        let result = tokenize("div hello p world a test");
527        assert_eq!(result, vec!["hello", "world", "test"]);
528    }
529
530    #[test]
531    fn test_tokenize_very_long_text() {
532        let words: Vec<String> = (0..1000).map(|i| format!("word{}", i)).collect();
533        let text = words.join(" ");
534        let result = tokenize(&text);
535        assert_eq!(result.len(), 1000);
536        assert_eq!(result[0], "word0");
537        assert_eq!(result[999], "word999");
538    }
539
540    #[test]
541    fn test_tokenize_with_newlines_and_tabs() {
542        let result = tokenize("hello\nworld\ttest");
543        assert_eq!(result, vec!["hello", "world", "test"]);
544    }
545
546    #[test]
547    fn test_tokenize_with_carriage_return() {
548        let result = tokenize("hello\r\nworld\rtest");
549        assert_eq!(result, vec!["hello", "world", "test"]);
550    }
551
552    #[test]
553    fn test_tokenize_trailing_plus() {
554        let result = tokenize("GPL2+ LGPL3+");
555        assert_eq!(result, vec!["gpl2+", "lgpl3+"]);
556    }
557
558    #[test]
559    fn test_tokenize_leading_plus() {
560        let result = tokenize("+hello +world");
561        assert_eq!(result, vec!["hello", "world"]);
562    }
563
564    #[test]
565    fn test_tokenize_without_stopwords_preserves_all() {
566        let result = tokenize_without_stopwords("div p a br");
567        assert_eq!(result, vec!["div", "p", "a", "br"]);
568    }
569
570    #[test]
571    fn test_tokenize_without_stopwords_unicode() {
572        // With Unicode pattern [^_\W], we match Unicode letters like Python's re.UNICODE
573        let result = tokenize_without_stopwords("hello 世界");
574        assert_eq!(result, vec!["hello", "世界"]);
575    }
576
577    #[test]
578    fn test_tokenize_without_stopwords_only_special() {
579        let result = tokenize_without_stopwords("!@#$%");
580        assert!(result.is_empty());
581    }
582
583    #[test]
584    fn test_tokenize_consecutive_plus() {
585        let result = tokenize("a++b");
586        assert_eq!(result, vec!["a+", "b"]);
587    }
588
589    #[test]
590    fn test_tokenize_hyphenated_words() {
591        let result = tokenize("some-thing foo-bar");
592        assert_eq!(result, vec!["some", "thing", "foo", "bar"]);
593    }
594
595    #[test]
596    fn test_tokenize_email_address() {
597        let result = tokenize("test@example.com");
598        assert_eq!(result, vec!["test", "example", "com"]);
599    }
600
601    #[test]
602    fn test_tokenize_url() {
603        let result = tokenize("https://example.com/path");
604        assert_eq!(result, vec!["https", "example", "com", "path"]);
605    }
606
607    #[test]
608    fn test_tokenize_version_number() {
609        let result = tokenize("version 1.2.3");
610        assert_eq!(result, vec!["version", "1", "2", "3"]);
611    }
612
613    #[test]
614    fn test_tokenize_xml_entities() {
615        let result = tokenize("&lt;div&gt;hello&lt;/div&gt;");
616        assert_eq!(result, vec!["hello"]);
617    }
618
619    #[test]
620    fn test_tokenize_whitespace_only() {
621        let result = tokenize("   \t\n\r   ");
622        assert!(result.is_empty());
623    }
624
625    #[test]
626    fn test_tokenize_single_char() {
627        let result = tokenize("a");
628        assert!(result.is_empty());
629    }
630
631    #[test]
632    fn test_tokenize_single_word() {
633        let result = tokenize("hello");
634        assert_eq!(result, vec!["hello"]);
635    }
636
637    #[test]
638    fn test_tokenize_numbers_only() {
639        let result = tokenize("123 456 789");
640        assert_eq!(result, vec!["123", "456", "789"]);
641    }
642
643    #[test]
644    fn test_tokenize_alphanumeric_mixed() {
645        let result = tokenize("abc123 def456");
646        assert_eq!(result, vec!["abc123", "def456"]);
647    }
648
649    #[test]
650    fn test_tokenize_underscore_separated() {
651        let result = tokenize("hello_world foo_bar_baz");
652        assert_eq!(result, vec!["hello", "world", "foo", "bar", "baz"]);
653    }
654
655    #[test]
656    fn test_tokenize_all_stopwords_from_list() {
657        let result = tokenize("amp lt gt nbsp quot");
658        assert!(result.is_empty());
659    }
660
661    #[test]
662    fn test_parse_required_phrase_spans_single() {
663        let text = "This is {{enclosed}} in braces";
664        let spans = parse_required_phrase_spans(text);
665        assert_eq!(spans, vec![2..3]);
666    }
667
668    #[test]
669    fn test_parse_required_phrase_spans_multiword() {
670        let text = "This is {{a required phrase}} here";
671        let spans = parse_required_phrase_spans(text);
672        assert_eq!(spans, vec![2..4]);
673    }
674
675    #[test]
676    fn test_parse_required_phrase_spans_multiple() {
677        let text = "{{First}} and {{second}} phrase";
678        let spans = parse_required_phrase_spans(text);
679        assert_eq!(spans, vec![0..1, 2..3]);
680    }
681
682    #[test]
683    fn test_parse_required_phrase_spans_none() {
684        let text = "No required phrases here";
685        let spans = parse_required_phrase_spans(text);
686        assert!(spans.is_empty());
687    }
688
689    #[test]
690    fn test_parse_required_phrase_spans_empty_braces() {
691        let text = "Empty {{}} braces";
692        let spans = parse_required_phrase_spans(text);
693        assert!(spans.is_empty());
694    }
695
696    #[test]
697    fn test_parse_required_phrase_spans_nested() {
698        let text = "Nested {{ outer {{ inner }} }} braces";
699        let spans = parse_required_phrase_spans(text);
700        assert!(spans.is_empty());
701    }
702
703    #[test]
704    fn test_parse_required_phrase_spans_unclosed() {
705        let text = "Unclosed {{ phrase here";
706        let spans = parse_required_phrase_spans(text);
707        assert!(spans.is_empty());
708    }
709
710    #[test]
711    fn test_parse_required_phrase_spans_unopened() {
712        let text = "Unopened }} phrase here";
713        let spans = parse_required_phrase_spans(text);
714        assert!(spans.is_empty());
715    }
716
717    #[test]
718    fn test_tokenize_with_stopwords_basic() {
719        let text = "hello div world p test";
720        let (tokens, stopwords) = tokenize_with_stopwords(text);
721        assert_eq!(tokens, vec!["hello", "world", "test"]);
722        // "div" is stopword after "hello" (pos 0), "p" is stopword after "world" (pos 1)
723        assert_eq!(stopwords.get(&Some(0)), Some(&1));
724        assert_eq!(stopwords.get(&Some(1)), Some(&1));
725    }
726
727    #[test]
728    fn test_tokenize_with_stopwords_empty() {
729        let (tokens, stopwords) = tokenize_with_stopwords("");
730        assert!(tokens.is_empty());
731        assert!(stopwords.is_empty());
732    }
733
734    #[test]
735    fn test_tokenize_with_stopwords_no_stopwords() {
736        let text = "hello world test";
737        let (tokens, stopwords) = tokenize_with_stopwords(text);
738        assert_eq!(tokens, vec!["hello", "world", "test"]);
739        assert!(stopwords.is_empty());
740    }
741
742    #[test]
743    fn test_parse_required_phrase_spans_filters_stopwords_inside() {
744        let text = "{{hello a world}}";
745        let spans = parse_required_phrase_spans(text);
746        assert_eq!(spans, vec![0..2]);
747    }
748
749    #[test]
750    fn test_parse_required_phrase_spans_filters_stopwords_outside() {
751        let text = "{{Hello}} a {{world}}";
752        let spans = parse_required_phrase_spans(text);
753        assert_eq!(spans, vec![0..1, 1..2]);
754    }
755
756    #[test]
757    fn test_parse_required_phrase_spans_multiple_stopwords() {
758        let text = "{{a p div hello}}";
759        let spans = parse_required_phrase_spans(text);
760        assert_eq!(spans, vec![0..1]);
761    }
762
763    #[test]
764    fn test_parse_required_phrase_spans_case_insensitive_stopwords() {
765        let text = "{{HELLO A WORLD}}";
766        let spans = parse_required_phrase_spans(text);
767        assert_eq!(spans, vec![0..2]);
768    }
769}