Skip to main content

sphinx_ultra/
search.rs

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
12/// The environment-version map emitted by Sphinx 9.1.0's built-in domains.
13/// Ultra currently implements the `std` and `py` data used below, but keeping
14/// the complete built-in map makes the JavaScript contract match Sphinx's
15/// searchtools loader and gives consumers the same compatibility signal.
16pub 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/// Search index that mirrors Sphinx's search functionality
33#[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    /// Add a document to the search index
71    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        // Extract and index terms from content
84        self.index_content(docname_idx, content)?;
85
86        Ok(())
87    }
88
89    /// Add an object to the search index
90    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    /// Index content for full-text search
123    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    /// Extract Sphinx search words and their normalized terms from content.
144    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    /// `IndexBuilder.feed`'s term for one word under this index's language.
157    fn term_for_word(&self, word: &str) -> Option<String> {
158        match self.language.as_str() {
159            "en" => search_term(word),
160            // Every other language Ultra builds is Sphinx's base
161            // `SearchLanguage`: no stemming and no stopwords, lowercased by
162            // `feed`, digit-only words dropped by `word_filter`.
163            _ => index_term(word, word.to_lowercase(), |term| !is_digit_word(term)),
164        }
165    }
166
167    /// Search for documents matching a query
168    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        // Calculate scores for each document
181        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        // Convert to search results and sort by score
191        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); // Limit results
208
209        results
210    }
211
212    /// Generate an excerpt for search results
213    fn generate_excerpt(&self, _docname_idx: usize, _query_terms: &[String]) -> String {
214        // TODO: Implement excerpt generation
215        String::new()
216    }
217
218    /// Prune the search index by removing documents not in the given set
219    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        // Build new document lists and mapping
226        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        // Update document lists
237        self.docnames = new_docnames;
238        self.filenames = new_filenames;
239        self.titles = new_titles;
240
241        // Update terms with new document indices
242        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        // Remove empty terms
254        self.terms.retain(|_, matches| !matches.is_empty());
255
256        // Update objects with new document indices
257        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    /// Export search index to JSON format compatible with Sphinx
268    pub fn to_json(&self) -> Result<String> {
269        Ok(serde_json::to_string(&self.to_sphinx_value())?)
270    }
271
272    /// Freeze the in-memory index using Sphinx's 9.1 searchindex schema.
273    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// ---------------------------------------------------------------------------
315// Sphinx's IndexBuilder (sphinx/search/__init__.py:202-636)
316// ---------------------------------------------------------------------------
317
318/// What `_feed_visit_nodes` collects from one document: Sphinx's
319/// `WordStore`.
320#[derive(Debug, Default)]
321struct WordStore {
322    words: Vec<String>,
323    titles: Vec<(String, Option<String>)>,
324    title_words: Vec<String>,
325}
326
327/// Sphinx's `IndexBuilder`: fed one *resolved* document at a time by the
328/// build's resolve phase (Sphinx: `write_doc_serialized` → `index_page` →
329/// `feed`), then frozen once into the `searchindex.js` value.
330///
331/// Feeding the resolved doctree is what makes cross-reference text match:
332/// a `:doc:` or `:ref:` without an explicit title indexes the target's
333/// title, which is the text of the reference `ReferencesResolver` built.
334#[derive(Debug, Default)]
335pub struct SphinxIndexBuilder {
336    /// docname -> title (`_titles`); its key set is the document list.
337    titles: BTreeMap<String, String>,
338    /// docname -> source-relative filename (`_filenames`).
339    filenames: BTreeMap<String, String>,
340    /// stemmed word -> docnames (`_mapping`).
341    mapping: BTreeMap<String, BTreeSet<String>>,
342    /// stemmed title word -> docnames (`_title_mapping`).
343    title_mapping: BTreeMap<String, BTreeSet<String>>,
344    /// docname -> every title in the document with its anchor
345    /// (`_all_titles`).
346    all_titles: BTreeMap<String, Vec<(String, Option<String>)>>,
347    /// docname -> its sorted `(entry, target_id, main)` index entries
348    /// (`_index_entries`).
349    index_entries: BTreeMap<String, Vec<(String, String, bool)>>,
350    /// Sphinx memoises `stem` for the duration of each `feed`; one cache
351    /// for the whole build gives the same terms without re-stemming the
352    /// words every document repeats.
353    stems: HashMap<String, String>,
354}
355
356impl SphinxIndexBuilder {
357    pub fn new() -> Self {
358        Self::default()
359    }
360
361    /// `IndexBuilder.feed`, behind the `index_page` gate: a document whose
362    /// metadata carries `:nosearch:` (or `:no-search:`) is fed an empty
363    /// title and an empty document, so it keeps its slot in `docnames` and
364    /// contributes nothing else.
365    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            // A term this document's titles already carry is not indexed
400            // again as body text.
401            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    /// The memoised `stem` plus the "stemmer must not remove words" rule.
418    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    /// `IndexBuilder.freeze`: the `Search.setIndex` payload.
431    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
490/// `IndexBuilder.get_terms`: term -> the sorted indices of its documents.
491fn 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
507/// The `(entry, target_id, main)` triples `feed` records from the
508/// document's `index` nodes: every part `split_index_msg` splits an entry
509/// into, as the sorted set Sphinx builds. An entry `split_index_msg`
510/// rejects is skipped (`except ValueError: pass`); the general index is
511/// where it gets warned about.
512fn index_entries_of(env: &BuildEnvironment, docname: &str) -> Vec<(String, String, bool)> {
513    // Sphinx sorts `(entry, target_id, main)` with `main` as the string
514    // `'main'` or `''`, which orders `''` first.
515    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
537/// `IndexBuilder._word_collector`.
538///
539/// Sphinx's `MetadataCollector` has popped the `docinfo` out of the
540/// doctree by the time the collector runs; this crate leaves that field
541/// list in place (see [`crate::env::metadata`]), so the walk starts below
542/// it.
543fn 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
555/// `_feed_visit_nodes` (`search/__init__.py:592-636`) over the resolved
556/// doctree, with the two things Sphinx's write phase did to that tree
557/// before the collector saw it folded in: a `toctree` node is visited as
558/// the tree [`crate::env::toctree::resolve_toctree`] replaces it with
559/// (`get_and_resolve_doctree`), and a `system_message` not at all
560/// (`FilterSystemMessages` removes every one at the default
561/// `keep_warnings = False`).
562fn 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        // skip nodes marked with a 'no-search' class
574        return;
575    }
576    match node.kind {
577        "raw" => {
578            if raw_format_is_html(node) {
579                // "Some people might put content in raw HTML that should be
580                // searched, so we just amateurishly strip HTML tags and
581                // index the remaining content"
582                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            // The first title met is the document's; only later ones carry
604            // their parent's anchor.
605            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
621/// `_is_meta_keywords` for the English search language.
622fn 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
631/// `'html' in node.get('format', '').split()`.
632fn 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
639/// The three substitutions Sphinx runs over raw HTML before indexing it.
640fn 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
651/// Render `Search.setIndex(...)` as `sphinx.search.js_index` does: the
652/// payload is `json.dumps(data, separators=(',', ':'), sort_keys=True)`,
653/// so every object's keys are sorted and, with the default
654/// `ensure_ascii`, every non-ASCII character is a `\uXXXX` escape.
655/// `objtypes` and `objnames` are keyed by int in Sphinx, so their keys sort
656/// numerically rather than as the strings JSON makes of them.
657pub 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
702/// `json.encoder.py_encode_basestring_ascii`: `"` and `\` escaped, the
703/// five short escapes, and every other character outside `U+0020..U+007E`
704/// as `\uXXXX` (surrogate pairs above the BMP).
705fn 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
814/// `SearchLanguage.split`: Python's `\w+`. For a `str` pattern `\w` is
815/// `str.isalnum()` plus the underscore, i.e. the letter and number
816/// categories — narrower than `char::is_alphanumeric`, whose `Alphabetic`
817/// property also covers combining marks and some symbols, so a Devanagari
818/// vowel sign or an enclosed letter would join a word here that Sphinx
819/// splits on.
820fn 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
827/// The term Sphinx's English search indexes for `word`, or `None` when the
828/// word is dropped: [`index_term`] over the Snowball stem and
829/// `SearchEnglish.word_filter`.
830fn search_term(word: &str) -> Option<String> {
831    index_term(word, stem_english(word), english_word_filter)
832}
833
834/// The one rule `IndexBuilder.feed` applies to title words and body words
835/// alike: the stem when `word_filter` accepts it, else the unstemmed word
836/// when it accepts *that* ("the stemmer must not remove words from search
837/// index" — `Some` stems to the stopword `some` and is indexed verbatim),
838/// else nothing.
839fn 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
849/// `SearchEnglish.word_filter`, which runs on the *stemmed* word.
850fn english_word_filter(word: &str) -> bool {
851    !is_digit_word(word) && !is_english_stopword(word)
852}
853
854/// Python `str.isdigit()`: non-empty and every character a digit.
855fn is_digit_word(word: &str) -> bool {
856    !word.is_empty() && word.chars().all(is_sphinx_digit)
857}
858
859/// `SearchEnglish.stem`: Snowball English over the lowercased word.
860fn stem_english(word: &str) -> String {
861    EnglishStemmer::new(word).stem()
862}
863
864/// Match Python 3.12's `str.isdigit()` used by Sphinx's English search.
865fn 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
958/// Sphinx's `ENGLISH_STOPWORDS` (`search/en.py`), kept in byte order so
959/// [`is_english_stopword`] can bisect it.
960const 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            // Snowball 3's `a_3`: the text *before* the suffix is exactly
1274            // `succ`/`proc`/`exc`, so `exceedly` keeps its `eed` here (step
1275            // 1c and step 2 then take it to `exceed`), while the whole-word
1276            // exceptions of Snowball 2 would have let it become `excee`.
1277            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        // Snowball 3's `a_5` routes `ingly` with `ed`/`edly` through the
1290        // generic branch; only a bare `ing` consults the `a_4` exceptions
1291        // (`evening`, `inning`, ... stay; `dying` becomes `die`).
1292        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/// `IndexBuilder._objtypes`/`_objnames`: one index per `(domain, type)`
1550/// in first-seen order, with the type's localized name.
1551#[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
1580/// `IndexBuilder.get_objects` (`search/__init__.py:363-402`) over
1581/// [`crate::env::objects::domain_objects`].
1582fn 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
1609/// The same table for the direct [`HTMLBuilder`](crate::HTMLBuilder)
1610/// path, whose objects come from [`SearchIndex::add_object`] rather than a
1611/// domain registry.
1612fn 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
1644/// A direct-path `obj_type` is `domain:type`, or a bare std type.
1645pub(crate) fn split_object_type(obj_type: &str) -> (&str, &str) {
1646    obj_type.split_once(':').unwrap_or(("std", obj_type))
1647}
1648
1649/// The search priority of a direct-path object, which has no domain
1650/// registry behind it: Sphinx's std table, and the Python domain's `0` for
1651/// a module and `1` for anything else (aliases are a registry concept).
1652pub(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
1660/// `prefix, _, name = dispname.rpartition('.')`.
1661fn 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
1668/// The anchor abbreviation `get_objects` applies: `''` for an anchor equal
1669/// to the full name, `'-'` for `type-fullname`, else the anchor itself.
1670fn 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
1680/// Python `html.escape(s)` with its default `quote=True`.
1681fn py_html_escape(text: &str) -> String {
1682    text.replace('&', "&amp;")
1683        .replace('<', "&lt;")
1684        .replace('>', "&gt;")
1685        .replace('"', "&quot;")
1686        .replace('\'', "&#x27;")
1687}
1688
1689/// Search result returned by the search index
1690#[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
1699/// Search index builder for incremental updates
1700pub 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    /// Add or update a document in the search index
1714    pub fn add_or_update_document(
1715        &mut self,
1716        docname: String,
1717        filename: String,
1718        title: String,
1719        content: &str,
1720    ) -> Result<()> {
1721        // Remove existing document if it exists
1722        if self.processed_docs.contains(&docname) {
1723            self.remove_document(&docname);
1724        }
1725
1726        // Add the document
1727        self.index
1728            .add_document(docname.clone(), filename, title, content)?;
1729        self.processed_docs.insert(docname);
1730
1731        Ok(())
1732    }
1733
1734    /// Remove a document from the search index
1735    pub fn remove_document(&mut self, docname: &str) {
1736        if let Some(docname_idx) = self.index.docnames.iter().position(|d| d == docname) {
1737            // Remove from document lists
1738            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            // Update indices in terms
1747            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            // Remove empty terms
1761            self.index.terms.retain(|_, matches| !matches.is_empty());
1762
1763            // Update indices in objects
1764            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    /// Get the built search index
1780    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        // `IndexBuilder.feed`: "the stemmer must not remove words from
1847        // search index" — only a word that is a stopword *itself* is dropped.
1848        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        // Title terms go through the same language rule as body terms.
1890        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        // Sphinx's `ENGLISH_STOPWORDS` has 174 entries; the golden fixture
1979        // pins their effect word by word, this pins the table's shape.
1980        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        // The filter is case-sensitive: `Some` is not the stopword `some`.
1989        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        // Apostrophes never reach the stemmer through `split` (`\w+` cuts
2009        // `can't` into `can` and `t`), so only `\w`-only inputs are
2010        // observable behaviour.
2011        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        // Vowel signs (`Mc`/`Mn`) split a Devanagari word where
2089        // `char::is_alphanumeric` would keep it whole.
2090        ("नमस्ते", &["नमस", "त"]),
2091        // Enclosed alphanumerics are symbols (`So`), not letters.
2092        ("ⓐⓑⓒ 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        // A glossary term has `searchprio=-1` and is not listed; the bare
2208        // `envvar` is a std type at priority 1; a module is priority 0 and
2209        // its `module-pkg` anchor abbreviates to `-`.
2210        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    /// The builder's read, merge and resolve steps in miniature: parse every
2229    /// `(docname, source)` into one environment that knows each document's
2230    /// title, metadata and toc, then feed them all. Reading everything
2231    /// first matters — a toctree resolves against the tocs of the documents
2232    /// it names.
2233    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        // Section titles and the caption are titles; the hidden toctree's
2306        // caption is not; the duplicate heading lists in document order.
2307        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        // Title words are not body terms for their own document, child
2327        // titles are body text of the parent through the resolved toctree,
2328        // and the comment is not indexed.
2329        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        // The hidden document is indexed itself; only its parent's toctree
2334        // does not mention it.
2335        assert_eq!(terms["hiddenword"], serde_json::json!(1));
2336        assert!(!terms.contains_key("hidden"));
2337        // `Some`, `You` and `can` survive as unstemmed words; `not`, `do`
2338        // and `this` are stopwords.
2339        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        // The leading field list is metadata, not body text.
2370        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}