1use anyhow::Result;
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4use serde_json::{Map, Value};
5use std::collections::{BTreeMap, BTreeSet, HashMap};
6use std::sync::LazyLock;
7
8use crate::doctree::{kinds, AttrValue, Doctree, Node};
9use crate::env::objects::{object_type_name, std_search_priority};
10use crate::env::BuildEnvironment;
11
12pub fn sphinx_91_envversion() -> BTreeMap<String, u32> {
17 BTreeMap::from([
18 ("sphinx".to_string(), 66),
19 ("sphinx.domains.c".to_string(), 3),
20 ("sphinx.domains.changeset".to_string(), 1),
21 ("sphinx.domains.citation".to_string(), 1),
22 ("sphinx.domains.cpp".to_string(), 9),
23 ("sphinx.domains.index".to_string(), 1),
24 ("sphinx.domains.javascript".to_string(), 3),
25 ("sphinx.domains.math".to_string(), 2),
26 ("sphinx.domains.python".to_string(), 4),
27 ("sphinx.domains.rst".to_string(), 2),
28 ("sphinx.domains.std".to_string(), 2),
29 ])
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct SearchIndex {
35 pub docnames: Vec<String>,
36 pub filenames: Vec<String>,
37 pub titles: Vec<String>,
38 pub terms: HashMap<String, Vec<DocumentMatch>>,
39 pub objects: HashMap<String, ObjectReference>,
40 pub objnames: HashMap<String, String>,
41 pub objtypes: HashMap<String, String>,
42 pub language: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct DocumentMatch {
47 pub docname_idx: usize,
48 pub title_score: f32,
49 pub content_score: f32,
50 pub positions: Vec<usize>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ObjectReference {
55 pub docname_idx: usize,
56 pub anchor: Option<String>,
57 pub name: String,
58 pub description: Option<String>,
59 pub obj_type: String,
60}
61
62impl SearchIndex {
63 pub fn new(language: String) -> Self {
64 Self {
65 language,
66 ..Default::default()
67 }
68 }
69
70 pub fn add_document(
72 &mut self,
73 docname: String,
74 filename: String,
75 title: String,
76 content: &str,
77 ) -> Result<()> {
78 let docname_idx = self.docnames.len();
79 self.docnames.push(docname);
80 self.filenames.push(filename);
81 self.titles.push(title);
82
83 self.index_content(docname_idx, content)?;
85
86 Ok(())
87 }
88
89 pub fn add_object(
91 &mut self,
92 name: String,
93 docname: &str,
94 anchor: Option<String>,
95 obj_type: &str,
96 description: Option<String>,
97 ) -> Result<()> {
98 let docname_idx = self
99 .docnames
100 .iter()
101 .position(|d| d == docname)
102 .unwrap_or_else(|| {
103 self.docnames.push(docname.to_string());
104 self.docnames.len() - 1
105 });
106
107 let object_ref = ObjectReference {
108 docname_idx,
109 anchor,
110 name: name.clone(),
111 description,
112 obj_type: obj_type.to_string(),
113 };
114
115 self.objects.insert(name, object_ref);
116 self.objtypes
117 .insert(obj_type.to_string(), obj_type.to_string());
118
119 Ok(())
120 }
121
122 fn index_content(&mut self, docname_idx: usize, content: &str) -> Result<()> {
124 let words = self.extract_words(content);
125
126 for (normalized_word, positions) in words {
127 let doc_match = DocumentMatch {
128 docname_idx,
129 title_score: 0.0,
130 content_score: positions.len() as f32,
131 positions,
132 };
133
134 self.terms
135 .entry(normalized_word)
136 .or_default()
137 .push(doc_match);
138 }
139
140 Ok(())
141 }
142
143 fn extract_words(&self, content: &str) -> HashMap<String, Vec<usize>> {
145 let mut words = HashMap::new();
146
147 for (position, word) in search_words(content).into_iter().enumerate() {
148 if let Some(term) = self.term_for_word(&word) {
149 words.entry(term).or_insert_with(Vec::new).push(position);
150 }
151 }
152
153 words
154 }
155
156 fn term_for_word(&self, word: &str) -> Option<String> {
158 match self.language.as_str() {
159 "en" => search_term(word),
160 _ => index_term(word, word.to_lowercase(), |term| !is_digit_word(term)),
164 }
165 }
166
167 pub fn search(&self, query: &str) -> Vec<SearchResult> {
169 let query_terms: Vec<String> = search_words(query)
170 .into_iter()
171 .filter_map(|word| self.term_for_word(&word))
172 .collect();
173
174 if query_terms.is_empty() {
175 return Vec::new();
176 }
177
178 let mut doc_scores: HashMap<usize, f32> = HashMap::new();
179
180 for term in &query_terms {
182 if let Some(matches) = self.terms.get(term) {
183 for doc_match in matches {
184 let score = doc_match.title_score * 5.0 + doc_match.content_score;
185 *doc_scores.entry(doc_match.docname_idx).or_insert(0.0) += score;
186 }
187 }
188 }
189
190 let mut results: Vec<SearchResult> = doc_scores
192 .into_iter()
193 .map(|(docname_idx, score)| SearchResult {
194 docname: self.docnames[docname_idx].clone(),
195 filename: self.filenames.get(docname_idx).cloned().unwrap_or_default(),
196 title: self.titles.get(docname_idx).cloned().unwrap_or_default(),
197 score,
198 excerpt: self.generate_excerpt(docname_idx, &query_terms),
199 })
200 .collect();
201
202 results.sort_by(|a, b| {
203 b.score
204 .partial_cmp(&a.score)
205 .unwrap_or(std::cmp::Ordering::Equal)
206 });
207 results.truncate(50); results
210 }
211
212 fn generate_excerpt(&self, _docname_idx: usize, _query_terms: &[String]) -> String {
214 String::new()
216 }
217
218 pub fn prune(&mut self, valid_docs: &std::collections::HashSet<String>) {
220 let mut new_docnames = Vec::new();
221 let mut new_filenames = Vec::new();
222 let mut new_titles = Vec::new();
223 let mut doc_mapping = HashMap::new();
224
225 for (old_idx, docname) in self.docnames.iter().enumerate() {
227 if valid_docs.contains(docname) {
228 let new_idx = new_docnames.len();
229 doc_mapping.insert(old_idx, new_idx);
230 new_docnames.push(docname.clone());
231 new_filenames.push(self.filenames.get(old_idx).cloned().unwrap_or_default());
232 new_titles.push(self.titles.get(old_idx).cloned().unwrap_or_default());
233 }
234 }
235
236 self.docnames = new_docnames;
238 self.filenames = new_filenames;
239 self.titles = new_titles;
240
241 for matches in self.terms.values_mut() {
243 matches.retain_mut(|doc_match| {
244 if let Some(&new_idx) = doc_mapping.get(&doc_match.docname_idx) {
245 doc_match.docname_idx = new_idx;
246 true
247 } else {
248 false
249 }
250 });
251 }
252
253 self.terms.retain(|_, matches| !matches.is_empty());
255
256 self.objects.retain(|_, obj_ref| {
258 if let Some(&new_idx) = doc_mapping.get(&obj_ref.docname_idx) {
259 obj_ref.docname_idx = new_idx;
260 true
261 } else {
262 false
263 }
264 });
265 }
266
267 pub fn to_json(&self) -> Result<String> {
269 Ok(serde_json::to_string(&self.to_sphinx_value())?)
270 }
271
272 pub fn to_sphinx_value(&self) -> Value {
274 let mut terms = BTreeMap::<String, BTreeSet<usize>>::new();
275 for (term, matches) in &self.terms {
276 for item in matches {
277 terms
278 .entry(term.clone())
279 .or_default()
280 .insert(item.docname_idx);
281 }
282 }
283
284 let mut titleterms = BTreeMap::<String, BTreeSet<usize>>::new();
285 for (index, title) in self.titles.iter().enumerate() {
286 for word in search_words(title) {
287 if let Some(term) = self.term_for_word(&word) {
288 titleterms.entry(term).or_default().insert(index);
289 }
290 }
291 }
292
293 let (objects, objtypes, objnames) = direct_search_objects(self);
294 freeze_search_value(FrozenSearchData {
295 docnames: self.docnames.clone(),
296 filenames: self.filenames.clone(),
297 titles: self.titles.clone(),
298 terms,
299 titleterms,
300 alltitles: self
301 .titles
302 .iter()
303 .enumerate()
304 .map(|(index, title)| (title.clone(), vec![(index, None)]))
305 .collect(),
306 objects,
307 objtypes,
308 objnames,
309 indexentries: BTreeMap::new(),
310 })
311 }
312}
313
314#[derive(Debug, Default)]
321struct WordStore {
322 words: Vec<String>,
323 titles: Vec<(String, Option<String>)>,
324 title_words: Vec<String>,
325}
326
327#[derive(Debug, Default)]
335pub struct SphinxIndexBuilder {
336 titles: BTreeMap<String, String>,
338 filenames: BTreeMap<String, String>,
340 mapping: BTreeMap<String, BTreeSet<String>>,
342 title_mapping: BTreeMap<String, BTreeSet<String>>,
344 all_titles: BTreeMap<String, Vec<(String, Option<String>)>>,
347 index_entries: BTreeMap<String, Vec<(String, String, bool)>>,
350 stems: HashMap<String, String>,
354}
355
356impl SphinxIndexBuilder {
357 pub fn new() -> Self {
358 Self::default()
359 }
360
361 pub fn feed(
366 &mut self,
367 docname: &str,
368 filename: &str,
369 title: &str,
370 doctree: &Doctree,
371 env: &BuildEnvironment,
372 ) {
373 let no_search = env.metadata.get(docname).is_some_and(|metadata| {
374 metadata.contains_key("nosearch") || metadata.contains_key("no-search")
375 });
376 let (title, store) = if no_search {
377 ("", WordStore::default())
378 } else {
379 (title, collect_words(doctree, docname, env))
380 };
381
382 self.titles.insert(docname.to_string(), title.to_string());
383 self.filenames
384 .insert(docname.to_string(), filename.to_string());
385 self.all_titles.insert(docname.to_string(), store.titles);
386
387 for word in &store.title_words {
388 if let Some(term) = self.term(word) {
389 self.title_mapping
390 .entry(term)
391 .or_default()
392 .insert(docname.to_string());
393 }
394 }
395 for word in &store.words {
396 let Some(term) = self.term(word) else {
397 continue;
398 };
399 let already_indexed = self
402 .title_mapping
403 .get(&term)
404 .is_some_and(|docnames| docnames.contains(docname));
405 if !already_indexed {
406 self.mapping
407 .entry(term)
408 .or_default()
409 .insert(docname.to_string());
410 }
411 }
412
413 self.index_entries
414 .insert(docname.to_string(), index_entries_of(env, docname));
415 }
416
417 fn term(&mut self, word: &str) -> Option<String> {
419 let stemmed = match self.stems.get(word) {
420 Some(stemmed) => stemmed.clone(),
421 None => {
422 let stemmed = stem_english(word);
423 self.stems.insert(word.to_string(), stemmed.clone());
424 stemmed
425 }
426 };
427 index_term(word, stemmed, english_word_filter)
428 }
429
430 pub fn freeze(&self, env: &BuildEnvironment) -> Value {
432 let docnames: Vec<String> = self.titles.keys().cloned().collect();
433 let titles: Vec<String> = self.titles.values().cloned().collect();
434 let filenames: Vec<String> = docnames
435 .iter()
436 .map(|docname| self.filenames.get(docname).cloned().unwrap_or_default())
437 .collect();
438 let fn2index: BTreeMap<String, usize> = docnames
439 .iter()
440 .enumerate()
441 .map(|(index, docname)| (docname.clone(), index))
442 .collect();
443
444 let terms = index_postings(&self.mapping, &fn2index);
445 let titleterms = index_postings(&self.title_mapping, &fn2index);
446 let (objects, objtypes, objnames) = search_objects(env, &fn2index);
447
448 let mut alltitles = BTreeMap::<String, Vec<(usize, Option<String>)>>::new();
449 for (docname, titlelist) in &self.all_titles {
450 let Some(&index) = fn2index.get(docname) else {
451 continue;
452 };
453 for (title, title_id) in titlelist {
454 alltitles
455 .entry(title.clone())
456 .or_default()
457 .push((index, title_id.clone()));
458 }
459 }
460
461 let mut indexentries = BTreeMap::<String, Vec<(usize, String, bool)>>::new();
462 for (docname, entries) in &self.index_entries {
463 let Some(&index) = fn2index.get(docname) else {
464 continue;
465 };
466 for (entry, entry_id, main) in entries {
467 indexentries.entry(entry.to_lowercase()).or_default().push((
468 index,
469 entry_id.clone(),
470 *main,
471 ));
472 }
473 }
474
475 freeze_search_value(FrozenSearchData {
476 docnames,
477 filenames,
478 titles,
479 terms,
480 titleterms,
481 alltitles,
482 objects,
483 objtypes,
484 objnames,
485 indexentries,
486 })
487 }
488}
489
490fn index_postings(
492 mapping: &BTreeMap<String, BTreeSet<String>>,
493 fn2index: &BTreeMap<String, usize>,
494) -> BTreeMap<String, BTreeSet<usize>> {
495 mapping
496 .iter()
497 .filter_map(|(term, docnames)| {
498 let indices: BTreeSet<usize> = docnames
499 .iter()
500 .filter_map(|docname| fn2index.get(docname).copied())
501 .collect();
502 (!indices.is_empty()).then(|| (term.clone(), indices))
503 })
504 .collect()
505}
506
507fn index_entries_of(env: &BuildEnvironment, docname: &str) -> Vec<(String, String, bool)> {
513 let mut entries = BTreeSet::<(String, String, &str)>::new();
516 for record in env
517 .index_entries
518 .get(docname)
519 .map(Vec::as_slice)
520 .unwrap_or_default()
521 {
522 let Ok(parts) = crate::env::genindex::split_index_msg(&record.entry_type, &record.value)
523 else {
524 continue;
525 };
526 let main = if record.main { "main" } else { "" };
527 for part in parts {
528 entries.insert((part, record.target_id.clone(), main));
529 }
530 }
531 entries
532 .into_iter()
533 .map(|(entry, target_id, main)| (entry, target_id, main == "main"))
534 .collect()
535}
536
537fn collect_words(doctree: &Doctree, docname: &str, env: &BuildEnvironment) -> WordStore {
544 let mut store = WordStore::default();
545 let docinfo = crate::env::metadata::docinfo_field_list(doctree);
546 for child in &doctree.root.children {
547 if docinfo.is_some_and(|docinfo| std::ptr::eq(docinfo, child)) {
548 continue;
549 }
550 feed_visit_nodes(child, &doctree.root.attrs.ids, &mut store, docname, env);
551 }
552 store
553}
554
555fn feed_visit_nodes(
563 node: &Node,
564 parent_ids: &[String],
565 store: &mut WordStore,
566 docname: &str,
567 env: &BuildEnvironment,
568) {
569 if node.kind == kinds::COMMENT || node.kind == kinds::SYSTEM_MESSAGE {
570 return;
571 }
572 if node.attrs.classes.iter().any(|class| class == "no-search") {
573 return;
575 }
576 match node.kind {
577 "raw" => {
578 if raw_format_is_html(node) {
579 let text = strip_html_tags(&node.astext());
583 store.words.extend(search_words(&text));
584 }
585 return;
586 }
587 kinds::TOCTREE => {
588 if let Some(resolved) = crate::env::toctree::resolve_toctree(env, docname, node) {
589 feed_visit_nodes(&resolved, parent_ids, store, docname, env);
590 }
591 return;
592 }
593 "meta" if is_meta_keywords(node) => {
594 if let Some(AttrValue::Str(content)) = node.get("content") {
595 store
596 .words
597 .extend(content.split(',').map(|keyword| keyword.trim().to_string()));
598 }
599 }
600 kinds::TEXT => store.words.extend(search_words(&node.astext())),
601 kinds::TITLE => {
602 let title = node.astext();
603 let is_main_title = store.titles.is_empty();
606 let title_id = if is_main_title {
607 None
608 } else {
609 parent_ids.first().cloned()
610 };
611 store.titles.push((title.clone(), title_id));
612 store.title_words.extend(search_words(&title));
613 }
614 _ => {}
615 }
616 for child in &node.children {
617 feed_visit_nodes(child, &node.attrs.ids, store, docname, env);
618 }
619}
620
621fn is_meta_keywords(node: &Node) -> bool {
623 matches!(node.get("name"), Some(AttrValue::Str(name)) if name == "keywords")
624 && match node.get("lang") {
625 None => true,
626 Some(AttrValue::Str(lang)) => lang == "en",
627 Some(_) => false,
628 }
629}
630
631fn raw_format_is_html(node: &Node) -> bool {
633 match node.get("format") {
634 Some(AttrValue::Str(format)) => format.split_whitespace().any(|f| f == "html"),
635 _ => false,
636 }
637}
638
639fn strip_html_tags(text: &str) -> String {
641 static STYLE: LazyLock<Regex> =
642 LazyLock::new(|| Regex::new(r"(?is)<style.*?</style>").unwrap());
643 static SCRIPT: LazyLock<Regex> =
644 LazyLock::new(|| Regex::new(r"(?is)<script.*?</script>").unwrap());
645 static TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<[^<]+?>").unwrap());
646 let text = STYLE.replace_all(text, "");
647 let text = SCRIPT.replace_all(&text, "");
648 TAG.replace_all(&text, "").into_owned()
649}
650
651pub fn dumps_sphinx_index(value: &Value) -> Result<String> {
658 let mut out = String::from("Search.setIndex(");
659 write_python_json(value, &mut out, false);
660 out.push(')');
661 Ok(out)
662}
663
664fn write_python_json(value: &Value, out: &mut String, int_keys: bool) {
665 match value {
666 Value::Null => out.push_str("null"),
667 Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
668 Value::Number(number) => out.push_str(&number.to_string()),
669 Value::String(string) => write_python_json_string(string, out),
670 Value::Array(items) => {
671 out.push('[');
672 for (index, item) in items.iter().enumerate() {
673 if index > 0 {
674 out.push(',');
675 }
676 write_python_json(item, out, false);
677 }
678 out.push(']');
679 }
680 Value::Object(map) => {
681 let mut keys: Vec<&String> = map.keys().collect();
682 if int_keys {
683 keys.sort_by_key(|key| key.parse::<u64>().unwrap_or(u64::MAX));
684 } else {
685 keys.sort();
686 }
687 out.push('{');
688 for (index, key) in keys.iter().enumerate() {
689 if index > 0 {
690 out.push(',');
691 }
692 write_python_json_string(key, out);
693 out.push(':');
694 let child_int_keys = matches!(key.as_str(), "objtypes" | "objnames");
695 write_python_json(&map[key.as_str()], out, child_int_keys);
696 }
697 out.push('}');
698 }
699 }
700}
701
702fn write_python_json_string(string: &str, out: &mut String) {
706 use std::fmt::Write as _;
707
708 out.push('"');
709 for character in string.chars() {
710 match character {
711 '"' => out.push_str("\\\""),
712 '\\' => out.push_str("\\\\"),
713 '\n' => out.push_str("\\n"),
714 '\r' => out.push_str("\\r"),
715 '\t' => out.push_str("\\t"),
716 '\u{8}' => out.push_str("\\b"),
717 '\u{c}' => out.push_str("\\f"),
718 ' '..='~' => out.push(character),
719 _ => {
720 let mut units = [0u16; 2];
721 for unit in character.encode_utf16(&mut units) {
722 let _ = write!(out, "\\u{unit:04x}");
723 }
724 }
725 }
726 }
727 out.push('"');
728}
729
730struct FrozenSearchData {
731 docnames: Vec<String>,
732 filenames: Vec<String>,
733 titles: Vec<String>,
734 terms: BTreeMap<String, BTreeSet<usize>>,
735 titleterms: BTreeMap<String, BTreeSet<usize>>,
736 alltitles: BTreeMap<String, Vec<(usize, Option<String>)>>,
737 objects: BTreeMap<String, Vec<[Value; 5]>>,
738 objtypes: BTreeMap<String, String>,
739 objnames: BTreeMap<String, [String; 3]>,
740 indexentries: BTreeMap<String, Vec<(usize, String, bool)>>,
741}
742
743fn freeze_search_value(data: FrozenSearchData) -> Value {
744 let FrozenSearchData {
745 docnames,
746 filenames,
747 titles,
748 terms,
749 titleterms,
750 alltitles,
751 objects,
752 objtypes,
753 objnames,
754 indexentries,
755 } = data;
756 let terms = postings_to_json(terms);
757 let titleterms = postings_to_json(titleterms);
758 let alltitles = alltitles
759 .into_iter()
760 .map(|(title, entries)| {
761 (
762 title,
763 entries
764 .into_iter()
765 .map(|(index, id)| serde_json::json!([index, id]))
766 .collect::<Vec<_>>()
767 .into(),
768 )
769 })
770 .collect::<Map<_, _>>();
771 let indexentries = indexentries
772 .into_iter()
773 .map(|(entry, locations)| {
774 (
775 entry,
776 locations
777 .into_iter()
778 .map(|(index, id, main)| serde_json::json!([index, id, main]))
779 .collect::<Vec<_>>()
780 .into(),
781 )
782 })
783 .collect::<Map<_, _>>();
784
785 serde_json::json!({
786 "alltitles": alltitles,
787 "docnames": docnames,
788 "envversion": sphinx_91_envversion(),
789 "filenames": filenames,
790 "indexentries": indexentries,
791 "objects": objects,
792 "objnames": objnames,
793 "objtypes": objtypes,
794 "terms": terms,
795 "titles": titles,
796 "titleterms": titleterms,
797 })
798}
799
800fn postings_to_json(postings: BTreeMap<String, BTreeSet<usize>>) -> Map<String, Value> {
801 postings
802 .into_iter()
803 .map(|(term, indices)| {
804 let value = if indices.len() == 1 {
805 Value::from(*indices.iter().next().unwrap())
806 } else {
807 Value::Array(indices.into_iter().map(Value::from).collect())
808 };
809 (term, value)
810 })
811 .collect()
812}
813
814fn search_words(text: &str) -> Vec<String> {
821 static WORD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[\p{L}\p{N}_]+").unwrap());
822 WORD.find_iter(text)
823 .map(|word| word.as_str().to_string())
824 .collect()
825}
826
827fn search_term(word: &str) -> Option<String> {
831 index_term(word, stem_english(word), english_word_filter)
832}
833
834fn index_term(word: &str, stemmed: String, word_filter: impl Fn(&str) -> bool) -> Option<String> {
840 if word_filter(&stemmed) {
841 Some(stemmed)
842 } else if word_filter(word) {
843 Some(word.to_string())
844 } else {
845 None
846 }
847}
848
849fn english_word_filter(word: &str) -> bool {
851 !is_digit_word(word) && !is_english_stopword(word)
852}
853
854fn is_digit_word(word: &str) -> bool {
856 !word.is_empty() && word.chars().all(is_sphinx_digit)
857}
858
859fn stem_english(word: &str) -> String {
861 EnglishStemmer::new(word).stem()
862}
863
864fn is_sphinx_digit(character: char) -> bool {
866 matches!(
867 character as u32,
868 0x30..=0x39
869 | 0xB2..=0xB3
870 | 0xB9
871 | 0x660..=0x669
872 | 0x6F0..=0x6F9
873 | 0x7C0..=0x7C9
874 | 0x966..=0x96F
875 | 0x9E6..=0x9EF
876 | 0xA66..=0xA6F
877 | 0xAE6..=0xAEF
878 | 0xB66..=0xB6F
879 | 0xBE6..=0xBEF
880 | 0xC66..=0xC6F
881 | 0xCE6..=0xCEF
882 | 0xD66..=0xD6F
883 | 0xDE6..=0xDEF
884 | 0xE50..=0xE59
885 | 0xED0..=0xED9
886 | 0xF20..=0xF29
887 | 0x1040..=0x1049
888 | 0x1090..=0x1099
889 | 0x1369..=0x1371
890 | 0x17E0..=0x17E9
891 | 0x1810..=0x1819
892 | 0x1946..=0x194F
893 | 0x19D0..=0x19DA
894 | 0x1A80..=0x1A89
895 | 0x1A90..=0x1A99
896 | 0x1B50..=0x1B59
897 | 0x1BB0..=0x1BB9
898 | 0x1C40..=0x1C49
899 | 0x1C50..=0x1C59
900 | 0x2070
901 | 0x2074..=0x2079
902 | 0x2080..=0x2089
903 | 0x2460..=0x2468
904 | 0x2474..=0x247C
905 | 0x2488..=0x2490
906 | 0x24EA
907 | 0x24F5..=0x24FD
908 | 0x24FF
909 | 0x2776..=0x277E
910 | 0x2780..=0x2788
911 | 0x278A..=0x2792
912 | 0xA620..=0xA629
913 | 0xA8D0..=0xA8D9
914 | 0xA900..=0xA909
915 | 0xA9D0..=0xA9D9
916 | 0xA9F0..=0xA9F9
917 | 0xAA50..=0xAA59
918 | 0xABF0..=0xABF9
919 | 0xFF10..=0xFF19
920 | 0x104A0..=0x104A9
921 | 0x10A40..=0x10A43
922 | 0x10D30..=0x10D39
923 | 0x10E60..=0x10E68
924 | 0x11052..=0x1105A
925 | 0x11066..=0x1106F
926 | 0x110F0..=0x110F9
927 | 0x11136..=0x1113F
928 | 0x111D0..=0x111D9
929 | 0x112F0..=0x112F9
930 | 0x11450..=0x11459
931 | 0x114D0..=0x114D9
932 | 0x11650..=0x11659
933 | 0x116C0..=0x116C9
934 | 0x11730..=0x11739
935 | 0x118E0..=0x118E9
936 | 0x11950..=0x11959
937 | 0x11C50..=0x11C59
938 | 0x11D50..=0x11D59
939 | 0x11DA0..=0x11DA9
940 | 0x11F50..=0x11F59
941 | 0x16A60..=0x16A69
942 | 0x16AC0..=0x16AC9
943 | 0x16B50..=0x16B59
944 | 0x1D7CE..=0x1D7FF
945 | 0x1E140..=0x1E149
946 | 0x1E2F0..=0x1E2F9
947 | 0x1E4F0..=0x1E4F9
948 | 0x1E950..=0x1E959
949 | 0x1F100..=0x1F10A
950 | 0x1FBF0..=0x1FBF9
951 )
952}
953
954fn is_english_stopword(word: &str) -> bool {
955 ENGLISH_STOPWORDS.binary_search(&word).is_ok()
956}
957
958const ENGLISH_STOPWORDS: &[&str] = &[
961 "a",
962 "about",
963 "above",
964 "after",
965 "again",
966 "against",
967 "all",
968 "am",
969 "an",
970 "and",
971 "any",
972 "are",
973 "aren't",
974 "as",
975 "at",
976 "be",
977 "because",
978 "been",
979 "before",
980 "being",
981 "below",
982 "between",
983 "both",
984 "but",
985 "by",
986 "can't",
987 "cannot",
988 "could",
989 "couldn't",
990 "did",
991 "didn't",
992 "do",
993 "does",
994 "doesn't",
995 "doing",
996 "don't",
997 "down",
998 "during",
999 "each",
1000 "few",
1001 "for",
1002 "from",
1003 "further",
1004 "had",
1005 "hadn't",
1006 "has",
1007 "hasn't",
1008 "have",
1009 "haven't",
1010 "having",
1011 "he",
1012 "he'd",
1013 "he'll",
1014 "he's",
1015 "her",
1016 "here",
1017 "here's",
1018 "hers",
1019 "herself",
1020 "him",
1021 "himself",
1022 "his",
1023 "how",
1024 "how's",
1025 "i",
1026 "i'd",
1027 "i'll",
1028 "i'm",
1029 "i've",
1030 "if",
1031 "in",
1032 "into",
1033 "is",
1034 "isn't",
1035 "it",
1036 "it's",
1037 "its",
1038 "itself",
1039 "let's",
1040 "me",
1041 "more",
1042 "most",
1043 "mustn't",
1044 "my",
1045 "myself",
1046 "no",
1047 "nor",
1048 "not",
1049 "of",
1050 "off",
1051 "on",
1052 "once",
1053 "only",
1054 "or",
1055 "other",
1056 "ought",
1057 "our",
1058 "ours",
1059 "ourselves",
1060 "out",
1061 "over",
1062 "own",
1063 "same",
1064 "shan't",
1065 "she",
1066 "she'd",
1067 "she'll",
1068 "she's",
1069 "should",
1070 "shouldn't",
1071 "so",
1072 "some",
1073 "such",
1074 "than",
1075 "that",
1076 "that's",
1077 "the",
1078 "their",
1079 "theirs",
1080 "them",
1081 "themselves",
1082 "then",
1083 "there",
1084 "there's",
1085 "these",
1086 "they",
1087 "they'd",
1088 "they'll",
1089 "they're",
1090 "they've",
1091 "this",
1092 "those",
1093 "through",
1094 "to",
1095 "too",
1096 "under",
1097 "until",
1098 "up",
1099 "very",
1100 "was",
1101 "wasn't",
1102 "we",
1103 "we'd",
1104 "we'll",
1105 "we're",
1106 "we've",
1107 "were",
1108 "weren't",
1109 "what",
1110 "what's",
1111 "when",
1112 "when's",
1113 "where",
1114 "where's",
1115 "which",
1116 "while",
1117 "who",
1118 "who's",
1119 "whom",
1120 "why",
1121 "why's",
1122 "with",
1123 "won't",
1124 "would",
1125 "wouldn't",
1126 "you",
1127 "you'd",
1128 "you'll",
1129 "you're",
1130 "you've",
1131 "your",
1132 "yours",
1133 "yourself",
1134 "yourselves",
1135];
1136
1137struct EnglishStemmer {
1138 chars: Vec<char>,
1139 region1_start: usize,
1140 region2_start: usize,
1141 y_found: bool,
1142}
1143
1144impl EnglishStemmer {
1145 fn new(word: &str) -> Self {
1146 Self {
1147 chars: word.to_lowercase().chars().collect(),
1148 region1_start: 0,
1149 region2_start: 0,
1150 y_found: false,
1151 }
1152 }
1153
1154 fn stem(mut self) -> String {
1155 let original = self.as_str();
1156 match original.as_str() {
1157 "andes" | "atlas" | "bias" | "cosmos" | "howe" | "news" | "sky" => {
1158 return original;
1159 }
1160 "early" => return "earli".to_string(),
1161 "gently" => return "gentl".to_string(),
1162 "idly" => return "idl".to_string(),
1163 "only" => return "onli".to_string(),
1164 "singly" => return "singl".to_string(),
1165 "skies" => return "sky".to_string(),
1166 "skis" => return "ski".to_string(),
1167 "ugly" => return "ugli".to_string(),
1168 _ => {}
1169 }
1170 if self.chars.len() < 3 {
1171 return original;
1172 }
1173
1174 self.prelude();
1175 self.mark_regions();
1176 self.step_1a();
1177 self.step_1b();
1178 self.step_1c();
1179 self.step_2();
1180 self.step_3();
1181 self.step_4();
1182 self.step_5();
1183 self.postlude();
1184 self.as_str()
1185 }
1186
1187 fn as_str(&self) -> String {
1188 self.chars.iter().collect()
1189 }
1190
1191 fn prelude(&mut self) {
1192 if self.chars.first() == Some(&'\'') {
1193 self.chars.remove(0);
1194 }
1195 if self.chars.first() == Some(&'y') {
1196 self.chars[0] = 'Y';
1197 self.y_found = true;
1198 }
1199 for index in 0..self.chars.len().saturating_sub(1) {
1200 if Self::is_vowel(self.chars[index]) && self.chars[index + 1] == 'y' {
1201 self.chars[index + 1] = 'Y';
1202 self.y_found = true;
1203 }
1204 }
1205 }
1206
1207 fn mark_regions(&mut self) {
1208 const SPECIAL_PREFIXES: &[&str] = &[
1209 "arsen", "commun", "emerg", "gener", "inter", "later", "organ", "past", "univers",
1210 ];
1211 self.region1_start = SPECIAL_PREFIXES
1212 .iter()
1213 .find_map(|prefix| self.starts_with(prefix).then(|| prefix.chars().count()))
1214 .unwrap_or_else(|| self.region_after(0));
1215 self.region2_start = self.region_after(self.region1_start);
1216 }
1217
1218 fn region_after(&self, start: usize) -> usize {
1219 let mut index = start;
1220 while index < self.chars.len() && !Self::is_vowel(self.chars[index]) {
1221 index += 1;
1222 }
1223 if index == self.chars.len() {
1224 return self.chars.len();
1225 }
1226 index += 1;
1227 while index < self.chars.len() && Self::is_vowel(self.chars[index]) {
1228 index += 1;
1229 }
1230 if index == self.chars.len() {
1231 return self.chars.len();
1232 }
1233 index + 1
1234 }
1235
1236 fn step_1a(&mut self) {
1237 if let Some(suffix) = self.matching_suffix(&["'s'", "'s", "'"]) {
1238 self.remove_suffix(suffix);
1239 }
1240 let Some(suffix) = self.matching_suffix(&["sses", "ied", "ies", "us", "ss", "s"]) else {
1241 return;
1242 };
1243 match suffix {
1244 "sses" => self.replace_suffix("sses", "ss"),
1245 "ied" | "ies" => {
1246 let replacement = if self.suffix_start(suffix).unwrap() >= 2 {
1247 "i"
1248 } else {
1249 "ie"
1250 };
1251 self.replace_suffix(suffix, replacement);
1252 }
1253 "s" => {
1254 let start = self.suffix_start("s").unwrap();
1255 if start > 0 {
1256 let mut cursor = start - 1;
1257 while cursor > 0 && !Self::is_vowel(self.chars[cursor - 1]) {
1258 cursor -= 1;
1259 }
1260 if cursor > 0 && Self::is_vowel(self.chars[cursor - 1]) {
1261 self.remove_suffix("s");
1262 }
1263 }
1264 }
1265 "us" | "ss" => {}
1266 _ => unreachable!(),
1267 }
1268 }
1269
1270 fn step_1b(&mut self) {
1271 if let Some(suffix) = self.matching_suffix(&["eedly", "eed"]) {
1272 let start = self.suffix_start(suffix).unwrap();
1273 let exception = ["succ", "proc", "exc"]
1278 .iter()
1279 .any(|stem| self.chars[..start].iter().copied().eq(stem.chars()));
1280 if start >= self.region1_start && !exception {
1281 self.replace_suffix(suffix, "ee");
1282 }
1283 return;
1284 }
1285 let Some(suffix) = self.matching_suffix(&["ingly", "edly", "ing", "ed"]) else {
1286 return;
1287 };
1288 let start = self.suffix_start(suffix).unwrap();
1289 if suffix == "ing" {
1293 let special = ["even", "cann", "inn", "earr", "herr", "out"];
1294 if special
1295 .iter()
1296 .any(|word| self.chars[..start].iter().copied().eq(word.chars()))
1297 {
1298 return;
1299 }
1300 if start == 2 && self.chars[start - 1] == 'y' && !Self::is_vowel(self.chars[start - 2])
1301 {
1302 self.chars.truncate(start - 1);
1303 self.chars.extend("ie".chars());
1304 return;
1305 }
1306 }
1307 if !self.chars[..start].iter().copied().any(Self::is_vowel) {
1308 return;
1309 }
1310 self.chars.truncate(start);
1311 if self.ends_with("at") || self.ends_with("bl") || self.ends_with("iz") {
1312 self.chars.push('e');
1313 } else if self.ends_with_double_consonant() {
1314 let double_start = self.chars.len() - 2;
1315 let keep_short_double =
1316 double_start == 1 && matches!(self.chars[double_start - 1], 'a' | 'e' | 'o');
1317 if !keep_short_double {
1318 self.chars.pop();
1319 }
1320 } else if self.chars.len() == self.region1_start && self.short_syllable(self.chars.len()) {
1321 self.chars.push('e');
1322 }
1323 }
1324
1325 fn step_1c(&mut self) {
1326 let Some(suffix) = self.matching_suffix(&["y", "Y"]) else {
1327 return;
1328 };
1329 let start = self.suffix_start(suffix).unwrap();
1330 if start > 1 && !Self::is_vowel(self.chars[start - 1]) {
1331 self.replace_suffix(suffix, "i");
1332 }
1333 }
1334
1335 fn step_2(&mut self) {
1336 const RULES: &[(&str, &str)] = &[
1337 ("tional", "tion"),
1338 ("enci", "ence"),
1339 ("anci", "ance"),
1340 ("abli", "able"),
1341 ("entli", "ent"),
1342 ("ization", "ize"),
1343 ("izer", "ize"),
1344 ("ational", "ate"),
1345 ("ation", "ate"),
1346 ("ator", "ate"),
1347 ("alism", "al"),
1348 ("aliti", "al"),
1349 ("alli", "al"),
1350 ("fulness", "ful"),
1351 ("ousli", "ous"),
1352 ("ousness", "ous"),
1353 ("iveness", "ive"),
1354 ("iviti", "ive"),
1355 ("biliti", "ble"),
1356 ("bli", "ble"),
1357 ("fulli", "ful"),
1358 ("lessli", "less"),
1359 ("ogist", "og"),
1360 ("ogi", "og"),
1361 ("li", ""),
1362 ];
1363 let Some((suffix, replacement)) = RULES
1364 .iter()
1365 .filter(|(suffix, _)| self.ends_with(suffix))
1366 .max_by_key(|(suffix, _)| suffix.chars().count())
1367 .copied()
1368 else {
1369 return;
1370 };
1371 let start = self.suffix_start(suffix).unwrap();
1372 if start < self.region1_start {
1373 return;
1374 }
1375 if suffix == "ogi" && (start == 0 || self.chars[start - 1] != 'l') {
1376 return;
1377 }
1378 if suffix == "li"
1379 && (start == 0
1380 || !matches!(
1381 self.chars[start - 1],
1382 'c' | 'd' | 'e' | 'g' | 'h' | 'k' | 'm' | 'n' | 'r' | 't'
1383 ))
1384 {
1385 return;
1386 }
1387 self.replace_suffix(suffix, replacement);
1388 }
1389
1390 fn step_3(&mut self) {
1391 const RULES: &[(&str, &str, bool)] = &[
1392 ("tional", "tion", false),
1393 ("ational", "ate", false),
1394 ("alize", "al", false),
1395 ("icate", "ic", false),
1396 ("iciti", "ic", false),
1397 ("ical", "ic", false),
1398 ("ful", "", false),
1399 ("ness", "", false),
1400 ("ative", "", true),
1401 ];
1402 let Some((suffix, replacement, region2)) = RULES
1403 .iter()
1404 .filter(|(suffix, _, _)| self.ends_with(suffix))
1405 .max_by_key(|(suffix, _, _)| suffix.chars().count())
1406 .copied()
1407 else {
1408 return;
1409 };
1410 let start = self.suffix_start(suffix).unwrap();
1411 if start
1412 >= if region2 {
1413 self.region2_start
1414 } else {
1415 self.region1_start
1416 }
1417 {
1418 self.replace_suffix(suffix, replacement);
1419 }
1420 }
1421
1422 fn step_4(&mut self) {
1423 const SUFFIXES: &[&str] = &[
1424 "ement", "ance", "ence", "able", "ible", "ment", "ant", "ent", "ism", "ate", "iti",
1425 "ous", "ive", "ize", "al", "er", "ic", "ion",
1426 ];
1427 let Some(suffix) = SUFFIXES
1428 .iter()
1429 .copied()
1430 .filter(|suffix| self.ends_with(suffix))
1431 .max_by_key(|suffix| suffix.chars().count())
1432 else {
1433 return;
1434 };
1435 let start = self.suffix_start(suffix).unwrap();
1436 if start < self.region2_start {
1437 return;
1438 }
1439 if suffix == "ion" && (start == 0 || !matches!(self.chars[start - 1], 's' | 't')) {
1440 return;
1441 }
1442 self.remove_suffix(suffix);
1443 }
1444
1445 fn step_5(&mut self) {
1446 if self.ends_with("e") {
1447 let start = self.suffix_start("e").unwrap();
1448 if start >= self.region2_start
1449 || (start >= self.region1_start && !self.short_syllable(start))
1450 {
1451 self.remove_suffix("e");
1452 }
1453 } else if self.ends_with("l") {
1454 let start = self.suffix_start("l").unwrap();
1455 if start >= self.region2_start && start > 0 && self.chars[start - 1] == 'l' {
1456 self.remove_suffix("l");
1457 }
1458 }
1459 }
1460
1461 fn postlude(&mut self) {
1462 if self.y_found {
1463 for character in &mut self.chars {
1464 if *character == 'Y' {
1465 *character = 'y';
1466 }
1467 }
1468 }
1469 }
1470
1471 fn is_vowel(character: char) -> bool {
1472 matches!(character, 'a' | 'e' | 'i' | 'o' | 'u' | 'y')
1473 }
1474
1475 fn short_syllable(&self, end: usize) -> bool {
1476 if end >= 4 && self.chars[..end].ends_with(&['p', 'a', 's', 't']) {
1477 return true;
1478 }
1479 if end >= 3
1480 && !Self::is_vowel(self.chars[end - 1])
1481 && !matches!(self.chars[end - 1], 'w' | 'x' | 'Y')
1482 && Self::is_vowel(self.chars[end - 2])
1483 && !Self::is_vowel(self.chars[end - 3])
1484 {
1485 return true;
1486 }
1487 end == 2 && Self::is_vowel(self.chars[0]) && !Self::is_vowel(self.chars[1])
1488 }
1489
1490 fn ends_with_double_consonant(&self) -> bool {
1491 self.chars.len() >= 2
1492 && self.chars[self.chars.len() - 1] == self.chars[self.chars.len() - 2]
1493 && matches!(
1494 self.chars[self.chars.len() - 1],
1495 'b' | 'd' | 'f' | 'g' | 'm' | 'n' | 'p' | 'r' | 't'
1496 )
1497 }
1498
1499 fn starts_with(&self, prefix: &str) -> bool {
1500 self.chars
1501 .iter()
1502 .take(prefix.chars().count())
1503 .copied()
1504 .eq(prefix.chars())
1505 }
1506
1507 fn ends_with(&self, suffix: &str) -> bool {
1508 self.suffix_start(suffix).is_some()
1509 }
1510
1511 fn suffix_start(&self, suffix: &str) -> Option<usize> {
1512 let suffix_len = suffix.chars().count();
1513 if self.chars.len() < suffix_len {
1514 return None;
1515 }
1516 let start = self.chars.len() - suffix_len;
1517 self.chars[start..]
1518 .iter()
1519 .copied()
1520 .eq(suffix.chars())
1521 .then_some(start)
1522 }
1523
1524 fn matching_suffix<'a>(&self, suffixes: &'a [&'a str]) -> Option<&'a str> {
1525 suffixes
1526 .iter()
1527 .copied()
1528 .filter(|suffix| self.ends_with(suffix))
1529 .max_by_key(|suffix| suffix.chars().count())
1530 }
1531
1532 fn replace_suffix(&mut self, suffix: &str, replacement: &str) {
1533 let start = self.suffix_start(suffix).expect("suffix must be present");
1534 self.chars.truncate(start);
1535 self.chars.extend(replacement.chars());
1536 }
1537
1538 fn remove_suffix(&mut self, suffix: &str) {
1539 self.replace_suffix(suffix, "");
1540 }
1541}
1542
1543type SearchObjects = (
1544 BTreeMap<String, Vec<[Value; 5]>>,
1545 BTreeMap<String, String>,
1546 BTreeMap<String, [String; 3]>,
1547);
1548
1549#[derive(Default)]
1552struct ObjectTypes {
1553 indices: BTreeMap<(String, String), usize>,
1554 objtypes: BTreeMap<String, String>,
1555 objnames: BTreeMap<String, [String; 3]>,
1556}
1557
1558impl ObjectTypes {
1559 fn index_of(&mut self, domain: &str, objtype: &str) -> usize {
1560 let key = (domain.to_string(), objtype.to_string());
1561 if let Some(&index) = self.indices.get(&key) {
1562 return index;
1563 }
1564 let index = self.indices.len();
1565 self.objtypes
1566 .insert(index.to_string(), format!("{domain}:{objtype}"));
1567 self.objnames.insert(
1568 index.to_string(),
1569 [
1570 domain.to_string(),
1571 objtype.to_string(),
1572 object_type_name(domain, objtype),
1573 ],
1574 );
1575 self.indices.insert(key, index);
1576 index
1577 }
1578}
1579
1580fn search_objects(env: &BuildEnvironment, fn2index: &BTreeMap<String, usize>) -> SearchObjects {
1583 let mut types = ObjectTypes::default();
1584 let mut objects = BTreeMap::<String, Vec<[Value; 5]>>::new();
1585 for (domain, domain_objects) in crate::env::objects::domain_objects(env) {
1586 for object in domain_objects {
1587 let Some(&doc_index) = fn2index.get(&object.docname) else {
1588 continue;
1589 };
1590 if object.priority < 0 {
1591 continue;
1592 }
1593 let fullname = py_html_escape(&object.name);
1594 let dispname = py_html_escape(&object.dispname);
1595 let (prefix, name) = rpartition_dot(&dispname);
1596 let type_index = types.index_of(domain, &object.objtype);
1597 objects.entry(prefix).or_default().push([
1598 Value::from(doc_index),
1599 Value::from(type_index),
1600 Value::from(object.priority),
1601 Value::String(short_anchor(&object.anchor, &fullname, &object.objtype)),
1602 Value::String(name),
1603 ]);
1604 }
1605 }
1606 (objects, types.objtypes, types.objnames)
1607}
1608
1609fn direct_search_objects(index: &SearchIndex) -> SearchObjects {
1613 let mut references: Vec<&ObjectReference> = index.objects.values().collect();
1614 references.sort_by(|left, right| {
1615 left.obj_type
1616 .cmp(&right.obj_type)
1617 .then_with(|| left.name.cmp(&right.name))
1618 });
1619
1620 let mut types = ObjectTypes::default();
1621 let mut objects = BTreeMap::<String, Vec<[Value; 5]>>::new();
1622 for object in references {
1623 let (domain, objtype) = split_object_type(&object.obj_type);
1624 let priority = direct_object_priority(domain, objtype);
1625 if priority < 0 {
1626 continue;
1627 }
1628 let fullname = py_html_escape(&object.name);
1629 let dispname = py_html_escape(object.description.as_deref().unwrap_or(&object.name));
1630 let (prefix, name) = rpartition_dot(&dispname);
1631 let type_index = types.index_of(domain, objtype);
1632 let anchor = object.anchor.as_deref().unwrap_or_default();
1633 objects.entry(prefix).or_default().push([
1634 Value::from(object.docname_idx),
1635 Value::from(type_index),
1636 Value::from(priority),
1637 Value::String(short_anchor(anchor, &fullname, objtype)),
1638 Value::String(name),
1639 ]);
1640 }
1641 (objects, types.objtypes, types.objnames)
1642}
1643
1644pub(crate) fn split_object_type(obj_type: &str) -> (&str, &str) {
1646 obj_type.split_once(':').unwrap_or(("std", obj_type))
1647}
1648
1649pub(crate) fn direct_object_priority(domain: &str, objtype: &str) -> i32 {
1653 match domain {
1654 "std" => std_search_priority(objtype),
1655 "py" if objtype == "module" => 0,
1656 _ => 1,
1657 }
1658}
1659
1660fn rpartition_dot(dispname: &str) -> (String, String) {
1662 match dispname.rsplit_once('.') {
1663 Some((prefix, name)) => (prefix.to_string(), name.to_string()),
1664 None => (String::new(), dispname.to_string()),
1665 }
1666}
1667
1668fn short_anchor(anchor: &str, fullname: &str, objtype: &str) -> String {
1671 if anchor == fullname {
1672 String::new()
1673 } else if anchor == format!("{objtype}-{fullname}") {
1674 "-".to_string()
1675 } else {
1676 anchor.to_string()
1677 }
1678}
1679
1680fn py_html_escape(text: &str) -> String {
1682 text.replace('&', "&")
1683 .replace('<', "<")
1684 .replace('>', ">")
1685 .replace('"', """)
1686 .replace('\'', "'")
1687}
1688
1689#[derive(Debug, Clone, Serialize, Deserialize)]
1691pub struct SearchResult {
1692 pub docname: String,
1693 pub filename: String,
1694 pub title: String,
1695 pub score: f32,
1696 pub excerpt: String,
1697}
1698
1699pub struct SearchIndexBuilder {
1701 index: SearchIndex,
1702 processed_docs: std::collections::HashSet<String>,
1703}
1704
1705impl SearchIndexBuilder {
1706 pub fn new(language: String) -> Self {
1707 Self {
1708 index: SearchIndex::new(language),
1709 processed_docs: std::collections::HashSet::new(),
1710 }
1711 }
1712
1713 pub fn add_or_update_document(
1715 &mut self,
1716 docname: String,
1717 filename: String,
1718 title: String,
1719 content: &str,
1720 ) -> Result<()> {
1721 if self.processed_docs.contains(&docname) {
1723 self.remove_document(&docname);
1724 }
1725
1726 self.index
1728 .add_document(docname.clone(), filename, title, content)?;
1729 self.processed_docs.insert(docname);
1730
1731 Ok(())
1732 }
1733
1734 pub fn remove_document(&mut self, docname: &str) {
1736 if let Some(docname_idx) = self.index.docnames.iter().position(|d| d == docname) {
1737 self.index.docnames.remove(docname_idx);
1739 if docname_idx < self.index.filenames.len() {
1740 self.index.filenames.remove(docname_idx);
1741 }
1742 if docname_idx < self.index.titles.len() {
1743 self.index.titles.remove(docname_idx);
1744 }
1745
1746 for matches in self.index.terms.values_mut() {
1748 matches.retain_mut(|doc_match| {
1749 if doc_match.docname_idx == docname_idx {
1750 false
1751 } else if doc_match.docname_idx > docname_idx {
1752 doc_match.docname_idx -= 1;
1753 true
1754 } else {
1755 true
1756 }
1757 });
1758 }
1759
1760 self.index.terms.retain(|_, matches| !matches.is_empty());
1762
1763 self.index.objects.retain(|_, obj_ref| {
1765 if obj_ref.docname_idx == docname_idx {
1766 false
1767 } else if obj_ref.docname_idx > docname_idx {
1768 obj_ref.docname_idx -= 1;
1769 true
1770 } else {
1771 true
1772 }
1773 });
1774 }
1775
1776 self.processed_docs.remove(docname);
1777 }
1778
1779 pub fn build(self) -> SearchIndex {
1781 self.index
1782 }
1783}
1784
1785#[cfg(test)]
1786mod tests {
1787 use super::*;
1788
1789 #[test]
1790 fn test_search_index_creation() {
1791 let index = SearchIndex::new("en".to_string());
1792 assert_eq!(index.language, "en");
1793 assert_eq!(index.docnames.len(), 0);
1794 }
1795
1796 #[test]
1797 fn test_add_document() {
1798 let mut index = SearchIndex::new("en".to_string());
1799 index
1800 .add_document(
1801 "test".to_string(),
1802 "test.html".to_string(),
1803 "Test Document".to_string(),
1804 "This is a test document with some content.",
1805 )
1806 .unwrap();
1807
1808 assert_eq!(index.docnames.len(), 1);
1809 assert_eq!(index.docnames[0], "test");
1810 assert!(index.terms.contains_key("test"));
1811 assert!(index.terms.contains_key("document"));
1812 }
1813
1814 #[test]
1815 fn test_word_normalization() {
1816 assert_eq!(stem_english("running"), "run");
1817 assert_eq!(stem_english("walked"), "walk");
1818 assert_eq!(stem_english("tests"), "test");
1819 assert_eq!(stem_english("test"), "test");
1820 }
1821
1822 #[test]
1823 fn legacy_search_uses_sphinx_token_pipeline() {
1824 let mut index = SearchIndex::new("en".to_string());
1825 index
1826 .add_document(
1827 "test".to_string(),
1828 "test.html".to_string(),
1829 "Test".to_string(),
1830 "Running, cats. The quick-brown_fox ½ Ⅳ",
1831 )
1832 .unwrap();
1833
1834 assert!(index.terms.contains_key("run"));
1835 assert!(index.terms.contains_key("cat"));
1836 assert!(index.terms.contains_key("quick"));
1837 assert!(index.terms.contains_key("brown_fox"));
1838 assert!(!index.terms.contains_key("the"));
1839 assert!(index.terms.contains_key("½"));
1840 assert!(index.terms.contains_key("ⅳ"));
1841 assert!(!index.search("running, cats").is_empty());
1842 }
1843
1844 #[test]
1845 fn words_whose_stems_are_stopwords_are_indexed_verbatim_in_both_paths() {
1846 assert_eq!(search_term("all"), None);
1849 assert_eq!(search_term("some"), None);
1850 let kept = ["underlying", "others", "ifs", "mostly", "Some", "SOME", "A"];
1851 for word in kept {
1852 assert_eq!(
1853 search_term(word).as_deref(),
1854 Some(word),
1855 "Sphinx keeps {word:?} unstemmed"
1856 );
1857 }
1858
1859 let mut index = SearchIndex::new("en".to_string());
1860 index
1861 .add_document(
1862 "test".to_string(),
1863 "test.html".to_string(),
1864 "Title".to_string(),
1865 "all some underlying others ifs mostly Some SOME A",
1866 )
1867 .unwrap();
1868 for word in kept {
1869 assert!(index.terms.contains_key(word), "missing {word:?}");
1870 }
1871 assert!(!index.terms.contains_key("all"));
1872 assert!(!index.terms.contains_key("some"));
1873 let frozen_index = index.to_sphinx_value();
1874 let frozen_terms = frozen_index["terms"]
1875 .as_object()
1876 .expect("frozen terms must be an object");
1877 for word in kept {
1878 assert!(frozen_terms.contains_key(word), "frozen {word:?}");
1879 }
1880 }
1881
1882 #[test]
1883 fn non_english_indexes_lowercase_and_drops_digits_only() {
1884 let index = SearchIndex::new("de".to_string());
1885 assert_eq!(index.term_for_word("Laufen").as_deref(), Some("laufen"));
1886 assert_eq!(index.term_for_word("the").as_deref(), Some("the"));
1887 assert_eq!(index.term_for_word("2024"), None);
1888
1889 let mut index = SearchIndex::new("de".to_string());
1891 index
1892 .add_document(
1893 "test".to_string(),
1894 "test.html".to_string(),
1895 "Running Tests".to_string(),
1896 "running tests",
1897 )
1898 .unwrap();
1899 let value = index.to_sphinx_value();
1900 assert_eq!(
1901 value["titleterms"],
1902 serde_json::json!({"running": 0, "tests": 0})
1903 );
1904 assert_eq!(
1905 value["terms"],
1906 serde_json::json!({"running": 0, "tests": 0})
1907 );
1908 }
1909
1910 #[derive(Debug)]
1911 struct EnglishSearchWord {
1912 word: String,
1913 stem: String,
1914 term: Option<String>,
1915 }
1916
1917 fn english_search_fixture() -> Vec<EnglishSearchWord> {
1918 let content = match std::env::var_os("SPHINX_ULTRA_SEARCH_FIXTURE") {
1919 Some(path) => std::fs::read_to_string(path)
1920 .expect("SPHINX_ULTRA_SEARCH_FIXTURE must point to a readable TSV"),
1921 None => include_str!("../tests/fixtures/search_english.tsv").to_string(),
1922 };
1923 let headers: Vec<_> = content.lines().take(9).collect();
1924 assert!(headers.contains(&"# sphinx=9.1.0"));
1925 assert!(headers.contains(&"# docutils=0.22.4"));
1926 assert!(headers.contains(&"# snowballstemmer=3.1.1"));
1927 assert!(headers.contains(&"# generator=tools/gen_search_english_fixture.py"));
1928 assert!(headers.contains(&"# columns=word<TAB>stem<TAB>term"));
1929 content
1930 .lines()
1931 .filter(|line| !line.is_empty() && !line.starts_with('#'))
1932 .map(|line| {
1933 let fields: Vec<_> = line.split('\t').collect();
1934 assert_eq!(
1935 fields.len(),
1936 3,
1937 "search English TSV rows must have word, stem and term columns"
1938 );
1939 EnglishSearchWord {
1940 word: fields[0].to_string(),
1941 stem: fields[1].to_string(),
1942 term: (fields[2] != "-").then(|| fields[2].to_string()),
1943 }
1944 })
1945 .collect()
1946 }
1947
1948 #[test]
1949 fn english_search_matches_sphinx_golden_words() {
1950 let fixture = english_search_fixture();
1951
1952 assert!(
1953 fixture.len() >= 20_000,
1954 "golden corpus unexpectedly shrank to {} words",
1955 fixture.len()
1956 );
1957 for expected in &fixture {
1958 assert_eq!(
1959 stem_english(&expected.word),
1960 expected.stem,
1961 "stem mismatch for {:?}",
1962 expected.word
1963 );
1964 assert_eq!(
1965 search_term(&expected.word),
1966 expected.term,
1967 "term mismatch for {:?}",
1968 expected.word
1969 );
1970 }
1971 assert!(fixture
1972 .windows(2)
1973 .all(|words| words[0].word < words[1].word));
1974 }
1975
1976 #[test]
1977 fn english_stopword_table_is_sphinx_s_and_bisectable() {
1978 assert_eq!(ENGLISH_STOPWORDS.len(), 174);
1981 assert!(
1982 ENGLISH_STOPWORDS.windows(2).all(|pair| pair[0] < pair[1]),
1983 "the stopword table must stay sorted for binary_search"
1984 );
1985 for stopword in ["a", "can't", "doing", "it's", "yourselves"] {
1986 assert!(is_english_stopword(stopword), "{stopword:?}");
1987 }
1988 for word in ["Some", "The", "document", ""] {
1990 assert!(!is_english_stopword(word), "{word:?}");
1991 }
1992 }
1993
1994 #[test]
1995 fn splitter_matches_python_word_regex() {
1996 for (input, expected) in SPLIT_CASES {
1997 assert_eq!(
1998 search_words(input),
1999 *expected,
2000 "split mismatch for {:?}",
2001 input
2002 );
2003 }
2004 }
2005
2006 #[test]
2007 fn english_search_terms_match_sphinx_edge_cases() {
2008 let cases = [
2012 ("the", None),
2013 ("The", Some("The")),
2014 ("A", Some("A")),
2015 ("only", Some("onli")),
2016 ("inventory", Some("inventori")),
2017 ("eveningly", Some("even")),
2018 ("evening", Some("evening")),
2019 ("dying", Some("die")),
2020 ("exceedly", Some("exceed")),
2021 ("exceeded", Some("exceed")),
2022 ("agreed", Some("agre")),
2023 ("½", Some("½")),
2024 ("Ⅳ", Some("ⅳ")),
2025 ("¹", None),
2026 ("2024", None),
2027 ];
2028 for (word, expected) in cases {
2029 assert_eq!(
2030 search_term(word).as_deref(),
2031 expected,
2032 "term mismatch for {word:?}"
2033 );
2034 }
2035 }
2036
2037 #[test]
2038 fn raw_html_is_indexed_without_tags_styles_or_scripts() {
2039 let text = strip_html_tags(
2040 "<p>rawhtmlword <b>boldword</b></p><STYLE>css { rule }</STYLE><script>var x;</script>",
2041 );
2042 assert_eq!(search_words(&text), vec!["rawhtmlword", "boldword"]);
2043 }
2044
2045 #[test]
2046 fn dumps_like_python_json_with_ensure_ascii_and_int_keys() {
2047 let value = serde_json::json!({
2048 "titles": ["Café \u{1F600}", "tab\there"],
2049 "objtypes": {"0": "py:class", "10": "py:data", "2": "py:function"},
2050 "alltitles": {"10": [[0, null]], "2": [[1, "two"]]},
2051 });
2052 let dumped = dumps_sphinx_index(&value).unwrap();
2053 assert_eq!(
2054 dumped,
2055 "Search.setIndex({\"alltitles\":{\"10\":[[0,null]],\"2\":[[1,\"two\"]]},\
2056 \"objtypes\":{\"0\":\"py:class\",\"2\":\"py:function\",\"10\":\"py:data\"},\
2057 \"titles\":[\"Caf\\u00e9 \\ud83d\\ude00\",\"tab\\there\"]})"
2058 );
2059 }
2060
2061 const SPLIT_CASES: &[(&str, &[&str])] = &[
2062 (
2063 "The quick-brown_fox, can't email@example.com 123",
2064 &[
2065 "The",
2066 "quick",
2067 "brown_fox",
2068 "can",
2069 "t",
2070 "email",
2071 "example",
2072 "com",
2073 "123",
2074 ],
2075 ),
2076 (
2077 "Café naïve coöperate; one—two.",
2078 &["Café", "naïve", "coöperate", "one", "two"],
2079 ),
2080 (
2081 "a\u{301} e\u{301} C++ Rust's _private",
2082 &["a", "e", "C", "Rust", "s", "_private"],
2083 ),
2084 (
2085 "version 3.12.0 and U.S.A.",
2086 &["version", "3", "12", "0", "and", "U", "S", "A"],
2087 ),
2088 ("नमस्ते", &["नमस", "त"]),
2091 ("ⓐⓑⓒ x", &["x"]),
2093 ("a\u{345}b", &["a", "b"]),
2094 ];
2095
2096 #[test]
2097 fn test_search() {
2098 let mut index = SearchIndex::new("en".to_string());
2099 index
2100 .add_document(
2101 "test1".to_string(),
2102 "test1.html".to_string(),
2103 "First Test".to_string(),
2104 "This is the first test document.",
2105 )
2106 .unwrap();
2107 index
2108 .add_document(
2109 "test2".to_string(),
2110 "test2.html".to_string(),
2111 "Second Test".to_string(),
2112 "This is the second test document with more content.",
2113 )
2114 .unwrap();
2115
2116 let results = index.search("test document");
2117 assert!(!results.is_empty());
2118 assert!(results
2119 .iter()
2120 .any(|r| r.docname == "test1" || r.docname == "test2"));
2121 }
2122
2123 #[test]
2124 fn test_search_index_builder() {
2125 let mut builder = SearchIndexBuilder::new("en".to_string());
2126
2127 builder
2128 .add_or_update_document(
2129 "test".to_string(),
2130 "test.html".to_string(),
2131 "Test".to_string(),
2132 "Content",
2133 )
2134 .unwrap();
2135
2136 let index = builder.build();
2137 assert_eq!(index.docnames.len(), 1);
2138 }
2139
2140 #[test]
2141 fn direct_search_freeze_preserves_domain_objects() {
2142 let mut index = SearchIndex::new("en".to_string());
2143 index
2144 .add_document(
2145 "index".to_string(),
2146 "index.rst".to_string(),
2147 "Welcome".to_string(),
2148 "Body",
2149 )
2150 .unwrap();
2151 index
2152 .add_object(
2153 "Thing".to_string(),
2154 "index",
2155 Some("thing".to_string()),
2156 "py:function",
2157 Some("Thing".to_string()),
2158 )
2159 .unwrap();
2160
2161 let value = index.to_sphinx_value();
2162 assert_eq!(value["objtypes"]["0"], "py:function");
2163 assert_eq!(
2164 value["objnames"]["0"],
2165 serde_json::json!(["py", "function", "Python function"])
2166 );
2167 assert_eq!(
2168 value["objects"][""],
2169 serde_json::json!([[0, 0, 1, "thing", "Thing"]])
2170 );
2171 }
2172
2173 #[test]
2174 fn direct_search_drops_unsearchable_std_types_and_defaults_bare_types_to_std() {
2175 let mut index = SearchIndex::new("en".to_string());
2176 index
2177 .add_document(
2178 "index".to_string(),
2179 "index.rst".to_string(),
2180 "Welcome".to_string(),
2181 "Body",
2182 )
2183 .unwrap();
2184 index
2185 .add_object("Zebra".to_string(), "index", None, "term", None)
2186 .unwrap();
2187 index
2188 .add_object(
2189 "MYVAR".to_string(),
2190 "index",
2191 Some("envvar-MYVAR".to_string()),
2192 "envvar",
2193 None,
2194 )
2195 .unwrap();
2196 index
2197 .add_object(
2198 "pkg".to_string(),
2199 "index",
2200 Some("module-pkg".to_string()),
2201 "py:module",
2202 None,
2203 )
2204 .unwrap();
2205
2206 let value = index.to_sphinx_value();
2207 assert_eq!(
2211 value["objects"][""],
2212 serde_json::json!([[0, 0, 1, "-", "MYVAR"], [0, 1, 0, "-", "pkg"]])
2213 );
2214 assert_eq!(
2215 value["objtypes"],
2216 serde_json::json!({"0": "std:envvar", "1": "py:module"})
2217 );
2218 assert_eq!(
2219 value["objnames"]["0"],
2220 serde_json::json!(["std", "envvar", "environment variable"])
2221 );
2222 assert_eq!(
2223 value["objnames"]["1"],
2224 serde_json::json!(["py", "module", "Python module"])
2225 );
2226 }
2227
2228 fn feed_project(docs: &[(&str, &str)], env: &mut BuildEnvironment) -> SphinxIndexBuilder {
2234 use crate::env::toctree as env_toctree;
2235
2236 let found: BTreeSet<String> = docs.iter().map(|(name, _)| (*name).to_string()).collect();
2237 let mut read = Vec::new();
2238 for (docname, source) in docs {
2239 let doctree = crate::rst::parse_rst(
2240 source,
2241 &crate::rst::ParseOptions {
2242 source_path: format!("{docname}.rst"),
2243 sphinx: true,
2244 docname: (*docname).to_string(),
2245 found_docs: Some(std::sync::Arc::new(found.clone())),
2246 ..Default::default()
2247 },
2248 );
2249 let title = env_toctree::document_title(&doctree);
2250 env.longtitles.insert((*docname).to_string(), title.clone());
2251 env.titles.insert((*docname).to_string(), title.clone());
2252 env.metadata.insert(
2253 (*docname).to_string(),
2254 crate::env::metadata::document_metadata(&doctree),
2255 );
2256 let (toc, _) = env_toctree::build_toc(&doctree, docname);
2257 for toctree in env_toctree::toctree_copies(&toc) {
2258 env_toctree::note_toctree(env, docname, toctree);
2259 }
2260 env.tocs.insert((*docname).to_string(), toc);
2261 env.all_docs.insert((*docname).to_string(), 0);
2262 read.push((*docname, doctree, title.astext()));
2263 }
2264
2265 let mut builder = SphinxIndexBuilder::new();
2266 for (docname, doctree, title) in &read {
2267 builder.feed(docname, &format!("{docname}.rst"), title, doctree, env);
2268 }
2269 builder
2270 }
2271
2272 #[test]
2273 fn feed_collects_titles_like_sphinx() {
2274 let mut env = BuildEnvironment::default();
2275 let builder = feed_project(
2276 &[
2277 (
2278 "index",
2279 "Welcome Guide\n=============\n\n\
2280 .. toctree::\n :caption: Contents\n\n child\n\n\
2281 .. toctree::\n :hidden:\n :caption: Hidden Caption\n\n hidden\n\n\
2282 .. secretword in a comment\n\n\
2283 Installation Guide\n------------------\n\n\
2284 Some text. You can not do this.\n\n\
2285 Contents\n~~~~~~~~\n\nMore.\n",
2286 ),
2287 (
2288 "child",
2289 "Child Title\n===========\n\nChild body.\n\nChild Section\n-------------\n\nDeep.\n",
2290 ),
2291 ("hidden", "Hidden Doc\n==========\n\nhiddenword.\n"),
2292 ],
2293 &mut env,
2294 );
2295 let value = builder.freeze(&env);
2296
2297 assert_eq!(
2298 value["docnames"],
2299 serde_json::json!(["child", "hidden", "index"])
2300 );
2301 assert_eq!(
2302 value["titles"],
2303 serde_json::json!(["Child Title", "Hidden Doc", "Welcome Guide"])
2304 );
2305 assert_eq!(
2308 value["alltitles"],
2309 serde_json::json!({
2310 "Child Section": [[0, "child-section"]],
2311 "Child Title": [[0, null]],
2312 "Contents": [[2, null], [2, "contents"]],
2313 "Hidden Doc": [[1, null]],
2314 "Installation Guide": [[2, "installation-guide"]],
2315 "Welcome Guide": [[2, null]],
2316 })
2317 );
2318 assert_eq!(
2319 value["titleterms"],
2320 serde_json::json!({
2321 "child": 0, "content": 2, "doc": 1, "guid": 2, "hidden": 1,
2322 "instal": 2, "section": 0, "titl": 0, "welcom": 2,
2323 })
2324 );
2325 let terms = value["terms"].as_object().unwrap();
2326 assert!(!terms.contains_key("welcom"));
2330 assert_eq!(terms["child"], serde_json::json!(2));
2331 assert_eq!(terms["section"], serde_json::json!(2));
2332 assert!(!terms.contains_key("secretword"));
2333 assert_eq!(terms["hiddenword"], serde_json::json!(1));
2336 assert!(!terms.contains_key("hidden"));
2337 assert_eq!(terms["Some"], serde_json::json!(2));
2340 assert_eq!(terms["You"], serde_json::json!(2));
2341 assert_eq!(terms["can"], serde_json::json!(2));
2342 assert!(!terms.contains_key("not"));
2343 assert!(!terms.contains_key("thi"));
2344 }
2345
2346 #[test]
2347 fn nosearch_documents_keep_their_slot_and_contribute_nothing() {
2348 let mut env = BuildEnvironment::default();
2349 let builder = feed_project(
2350 &[
2351 (
2352 "hidden",
2353 ":nosearch:\n\nHidden Doc\n==========\n\nhiddenword text.\n",
2354 ),
2355 (
2356 "shown",
2357 ":tocdepth: 1\n\nShown Doc\n=========\n\nshownword text.\n",
2358 ),
2359 ],
2360 &mut env,
2361 );
2362 let value = builder.freeze(&env);
2363 assert_eq!(value["docnames"], serde_json::json!(["hidden", "shown"]));
2364 assert_eq!(value["titles"], serde_json::json!(["", "Shown Doc"]));
2365 assert_eq!(
2366 value["alltitles"],
2367 serde_json::json!({"Shown Doc": [[1, null]]})
2368 );
2369 assert_eq!(
2371 value["terms"],
2372 serde_json::json!({"shownword": 1, "text": 1})
2373 );
2374 }
2375
2376 #[test]
2377 fn index_entries_are_split_like_split_index_msg() {
2378 let mut env = BuildEnvironment::default();
2379 env.index_entries.insert(
2380 "index".to_string(),
2381 vec![
2382 crate::env::IndexEntryRecord {
2383 entry_type: "pair".to_string(),
2384 value: "alpha; beta".to_string(),
2385 target_id: "index-0".to_string(),
2386 main: false,
2387 category_key: None,
2388 },
2389 crate::env::IndexEntryRecord {
2390 entry_type: "single".to_string(),
2391 value: "Zebra".to_string(),
2392 target_id: "term-Zebra".to_string(),
2393 main: true,
2394 category_key: None,
2395 },
2396 crate::env::IndexEntryRecord {
2397 entry_type: "pair".to_string(),
2398 value: "lonely".to_string(),
2399 target_id: "index-1".to_string(),
2400 main: false,
2401 category_key: None,
2402 },
2403 ],
2404 );
2405 let builder = feed_project(&[("index", "T\n=\n\nBody.\n")], &mut env);
2406 let value = builder.freeze(&env);
2407 assert_eq!(
2408 value["indexentries"],
2409 serde_json::json!({
2410 "alpha": [[0, "index-0", false]],
2411 "beta": [[0, "index-0", false]],
2412 "zebra": [[0, "term-Zebra", true]],
2413 })
2414 );
2415 }
2416}