1use 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
21pub(crate) static STOPWORDS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
26 let mut set = HashSet::new();
27
28 for &word in &["amp", "apos", "gt", "lt", "nbsp", "quot"] {
30 set.insert(word);
31 }
32
33 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 set.insert("rem"); set.insert("dnl"); set.insert("para");
74 set.insert("ulink");
75
76 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 set.insert("x3c");
88 set.insert("x3e");
89
90 for &word in &[
92 "lists", "side", "nav", "height", "auto", "border", "padding", "width",
93 ] {
94 set.insert(word);
95 }
96
97 set.insert("head1");
99 set.insert("head2");
100 set.insert("head3");
101
102 set.insert("printf");
104
105 set.insert("echo");
107
108 set
109});
110
111static QUERY_PATTERN: LazyLock<Regex> =
121 LazyLock::new(|| Regex::new(r"[^_\W]+\+?[^_\W]*").expect("Invalid regex pattern"));
122
123pub 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 if !token.is_empty() && !STOPWORDS.contains(token) {
143 tokens.push(token.to_string());
144 }
145 }
146
147 tokens
148}
149
150pub 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 if !token.is_empty() {
169 tokens.push(token.to_string());
170 }
171 }
172
173 tokens
174}
175
176pub 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
212pub 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
229pub 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
302fn 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
355static REQUIRED_PHRASE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
358 Regex::new(r"(?:[^_\W]+\+?[^_\W]*|\{\{|\}\})").expect("Invalid required phrase pattern")
359});
360
361pub 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 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 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 "< markup >\"");
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 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 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("<div>hello</div>");
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 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}