Skip to main content

sphinx_ultra/env/
genindex.rs

1//! The `index` domain and the general index it feeds: Sphinx's
2//! `IndexDomain` (`domains/index.py`) collection half, and
3//! `IndexEntries.create_index` (`environment/adapters/indexentries.py:54-197`)
4//! — the assembly half that turns the collected 5-tuples into the grouped,
5//! sorted structure `genindex.html` renders.
6//!
7//! See `docs/superpowers/plans/2026-08-31-m2-wave4-research-spec-sphinx-env-toctree-domains.md`
8//! §6 for the attribute-by-attribute mapping this port is drawn from.
9//!
10//! **What feeds it.** Everything here reads `index` nodes out of a finished
11//! doctree, so its fidelity is bounded by the parse layer's. Two gaps are
12//! known and belong to that layer, not this one (neither is exercised by
13//! the environment-oracle corpus):
14//!
15//! * The `:index:` role (`IndexRole`, `domains/index.py:97-117`) is not
16//!   implemented, so `:index:`text`` parses as `problematic` and indexes
17//!   nothing.
18//! * `.. index::` with a `:name:` should anchor its entries on the id
19//!   docutils' `note_explicit_target` gives the *named* target
20//!   (`domains/index.py:78-83`), and consume no `index-N` serial. This
21//!   crate's `:name:`-optioned targets carry a name but no id throughout
22//!   the parse layer, so those entries anchor on `index-N` instead — and
23//!   because the serial *is* consumed, every later `index-N` in the same
24//!   document is shifted up by one as well, so the divergence is not
25//!   confined to the named directive's own entries.
26//!
27//! **Unicode caveat.** The sort and grouping keys normalize to NFD and
28//! lowercase exactly where Sphinx does, but two primitives are Rust's rather
29//! than Python's:
30//!
31//! * `str::to_lowercase`/`char::to_uppercase` are full Unicode case
32//!   mappings, like Python's `str.lower()`/`str.upper()` (both expand
33//!   `U+0130` to `i` + combining dot, both special-case final sigma). No
34//!   divergence is known for the corpus, and none is expected outside
35//!   locale-sensitive mappings (Turkish dotless i), which neither applies.
36//! * [`py_isalpha`] approximates Python's `str.isalpha()` (general category
37//!   `L*`) with Rust's `char::is_alphabetic` (the `Alphabetic` derived
38//!   property). The two differ for `Nl` (`Ⅷ`, `ↀ`) and for `Other_Alphabetic`
39//!   combining marks — which NFD *produces* — so an entry starting with a
40//!   Devanagari vowel sign, or a Roman-numeral character, would be grouped
41//!   under itself here and under `Symbols` by Sphinx. Closing that needs a
42//!   general-category table this crate does not carry.
43
44use std::collections::HashMap;
45use std::path::{Path, PathBuf};
46
47use log::warn;
48use serde::Serialize;
49use unicode_normalization::UnicodeNormalization;
50
51use crate::doctree::{AttrValue, Doctree, Node};
52use crate::env::{BuildEnvironment, IndexEntryRecord};
53use crate::error::{BuildWarning, WarningType};
54use crate::rst::block::py_repr;
55
56/// The `index` node kind, as the parse layer spells it.
57const INDEX: &str = "index";
58
59/// Sphinx's `\N{RIGHT-TO-LEFT MARK}`, stripped from every sort key.
60const RTL_MARK: char = '\u{200f}';
61
62/// Every warning this module raises carries `type='index'` and no subtype,
63/// which `show_warning_types` renders as a bare `[index]` suffix.
64const INDEX_CATEGORY: &str = "index";
65
66// ---------------------------------------------------------------------------
67// Collection: IndexDomain.process_doc
68// ---------------------------------------------------------------------------
69
70/// `IndexDomain.process_doc` (`domains/index.py:48-61`).
71///
72/// Harvests every `index` node's entries into `env.index_entries[docname]`,
73/// in document order. A node carrying an entry `split_index_msg` rejects
74/// warns *once* and is **removed from the doctree**, contributing none of
75/// its entries — Sphinx wraps the whole per-node validation loop in one
76/// `try`, so a single bad entry discards the node's good ones too.
77///
78/// The docname's (possibly empty) entry list is created unconditionally
79/// (`self.entries.setdefault(...)`), which is why a document with no index
80/// nodes at all still appears in `env.index_entries`.
81pub fn process_doc(
82    env: &mut BuildEnvironment,
83    docname: &str,
84    doctree: &mut Doctree,
85    path: &Path,
86    warnings: &mut Vec<BuildWarning>,
87) {
88    let mut collected: Vec<IndexEntryRecord> = Vec::new();
89    // Split borrow: the walk mutates `root` while the warning callback
90    // reads the `sources` table for each node's `(source, line)`.
91    let sources = std::mem::take(&mut doctree.sources);
92    visit(
93        &mut doctree.root,
94        docname,
95        &mut collected,
96        &mut |message, node| {
97            let source_path = sources
98                .get(node.span.source as usize)
99                .map(PathBuf::from)
100                .unwrap_or_else(|| path.to_path_buf());
101            warnings.push(
102                BuildWarning::new(
103                    source_path,
104                    Some(node.span.line as usize),
105                    message,
106                    WarningType::Other,
107                )
108                .with_category(Some(INDEX_CATEGORY.to_string())),
109            );
110        },
111    );
112    doctree.sources = sources;
113    env.index_entries
114        .entry(docname.to_string())
115        .or_default()
116        .extend(collected);
117}
118
119/// Pre-order walk mirroring `document.findall(addnodes.index)`, with the
120/// `node.parent.remove(node)` an invalid entry triggers applied in place.
121fn visit<F: FnMut(String, &Node)>(
122    node: &mut Node,
123    docname: &str,
124    out: &mut Vec<IndexEntryRecord>,
125    warn: &mut F,
126) {
127    let mut i = 0;
128    while i < node.children.len() {
129        if node.children[i].kind == INDEX {
130            let entries = index_node_entries(&node.children[i], docname);
131            match entries
132                .iter()
133                .try_for_each(|entry| split_index_msg(&entry.entry_type, &entry.value).map(|_| ()))
134            {
135                Ok(()) => out.extend(entries),
136                Err(message) => {
137                    warn(message, &node.children[i]);
138                    node.children.remove(i);
139                    continue;
140                }
141            }
142        } else {
143            visit(&mut node.children[i], docname, out, warn);
144        }
145        i += 1;
146    }
147}
148
149/// One `index` node's `entries` attribute, parsed back into records.
150///
151/// The three ways this can yield nothing are *not* the same, so they do not
152/// share an arm: an empty list is ordinary (an object description that
153/// indexes nothing writes one), an absent attribute loses no data because
154/// there was none, and a non-list attribute means entries that exist are
155/// being thrown away — the one case worth a log line.
156fn index_node_entries(node: &Node, docname: &str) -> Vec<IndexEntryRecord> {
157    match node.get("entries") {
158        Some(AttrValue::List(items)) => parse_index_entries(items, docname),
159        // Every producer writes a list (see
160        // [`crate::rst::block::index_entry_tuple`]), so a scalar here is a
161        // doctree cached before that was true. The harvest reads it as "no
162        // entries" and this document silently drops out of the general
163        // index; say so once rather than leaving it undiagnosable.
164        Some(_) => {
165            warn!(
166                "{docname}: an `index` node's `entries` is not a list attribute, so its \
167                 index entries were dropped. This is a doctree cached before the \
168                 list-attribute format; delete the cache directory to re-read it."
169            );
170            Vec::new()
171        }
172        None => Vec::new(),
173    }
174}
175
176/// The exact inverse of [`crate::rst::block::index_entry_tuple`]: each item
177/// of an `index` node's `entries` list attribute is the Python `str()` of a
178/// `(entry_type, value, target_id, main, category_key)` tuple.
179///
180/// An item that does not parse is dropped rather than panicking: only this
181/// crate writes these strings, so a malformed one is a bug here, not
182/// attacker-controlled input, and losing one index entry beats failing the
183/// build. It is logged for the same reason the non-list attribute above is:
184/// a dropped entry is invisible in the finished index.
185pub(crate) fn parse_index_entries(items: &[String], docname: &str) -> Vec<IndexEntryRecord> {
186    items
187        .iter()
188        .filter_map(|item| {
189            let parsed = parse_tuple(item);
190            if parsed.is_none() {
191                warn!("{docname}: unparsable `index` entry {item:?} was dropped");
192            }
193            parsed
194        })
195        .collect()
196}
197
198/// `('single', 'Alpha', 'index-0', '', None)` -> one record.
199fn parse_tuple(item: &str) -> Option<IndexEntryRecord> {
200    let chars: Vec<char> = item.chars().collect();
201    let mut at = 0usize;
202    expect(&chars, &mut at, '(')?;
203    let mut fields: Vec<Option<String>> = Vec::with_capacity(5);
204    for field in 0..5 {
205        fields.push(parse_literal(&chars, &mut at)?);
206        if field < 4 {
207            expect(&chars, &mut at, ',')?;
208            while chars.get(at) == Some(&' ') {
209                at += 1;
210            }
211        }
212    }
213    expect(&chars, &mut at, ')')?;
214    if at != chars.len() {
215        return None;
216    }
217    let main = fields[3].clone()?;
218    Some(IndexEntryRecord {
219        entry_type: fields[0].clone()?,
220        value: fields[1].clone()?,
221        target_id: fields[2].clone()?,
222        // Sphinx stores the literal marker string; only `'main'` and `''`
223        // are ever produced, and everything downstream tests it for truth.
224        main: !main.is_empty(),
225        category_key: fields[4].clone(),
226    })
227}
228
229fn expect(chars: &[char], at: &mut usize, want: char) -> Option<()> {
230    if chars.get(*at) == Some(&want) {
231        *at += 1;
232        Some(())
233    } else {
234        None
235    }
236}
237
238/// `None`, or a `'`/`"`-quoted string with Python's repr escapes.
239fn parse_literal(chars: &[char], at: &mut usize) -> Option<Option<String>> {
240    if chars[*at..].starts_with(&['N', 'o', 'n', 'e']) {
241        *at += 4;
242        return Some(None);
243    }
244    let quote = *chars.get(*at)?;
245    if quote != '\'' && quote != '"' {
246        return None;
247    }
248    *at += 1;
249    let mut out = String::new();
250    loop {
251        let c = *chars.get(*at)?;
252        *at += 1;
253        match c {
254            _ if c == quote => return Some(Some(out)),
255            '\\' => {
256                let escaped = *chars.get(*at)?;
257                *at += 1;
258                out.push(match escaped {
259                    'n' => '\n',
260                    'r' => '\r',
261                    't' => '\t',
262                    other => other,
263                });
264            }
265            other => out.push(other),
266        }
267    }
268}
269
270/// `split_index_msg` (`util/index_entries.py:4-18`). `Err` carries the
271/// `ValueError` text verbatim, which is what Sphinx logs.
272pub(crate) fn split_index_msg(entry_type: &str, value: &str) -> Result<Vec<String>, String> {
273    match entry_type {
274        "single" => split_into(2, "single", value).or_else(|_| split_into(1, "single", value)),
275        "pair" => split_into(2, "pair", value),
276        "triple" => split_into(3, "triple", value),
277        "see" | "seealso" => split_into(2, "see", value),
278        other => Err(invalid_entry(other, value)),
279    }
280}
281
282/// `_split_into` (`util/index_entries.py:21-27`): split at the first `n-1`
283/// semicolons, strip each part, and reject unless **every** part is
284/// non-empty (`len(list(filter(None, parts))) < n`).
285///
286/// `trim` stands in for Python's `str.strip()`. The two agree except on the
287/// C0 separators `U+001C`-`U+001F`, which Python's `str.isspace()` counts
288/// as whitespace and Unicode's `White_Space` property does not — so a part
289/// padded with one of those would strip in sphinx and not here.
290fn split_into(n: usize, entry_type: &str, value: &str) -> Result<Vec<String>, String> {
291    let parts: Vec<String> = value
292        .splitn(n, ';')
293        .map(|part| part.trim().to_string())
294        .collect();
295    if parts.iter().filter(|part| !part.is_empty()).count() < n {
296        return Err(invalid_entry(entry_type, value));
297    }
298    Ok(parts)
299}
300
301fn invalid_entry(entry_type: &str, value: &str) -> String {
302    format!("invalid {entry_type} index entry {}", py_repr(Some(value)))
303}
304
305// ---------------------------------------------------------------------------
306// Assembly: IndexEntries.create_index
307// ---------------------------------------------------------------------------
308
309/// One `(main, uri)` target: `main` is Sphinx's literal `"main"`/`""`.
310pub type IndexTarget = (String, String);
311
312/// One letter (or category-key) heading of the general index.
313#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
314pub struct IndexGroup {
315    pub group: String,
316    pub entries: Vec<IndexEntry>,
317}
318
319/// One top-level index entry with its targets and sub-entries.
320#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
321pub struct IndexEntry {
322    pub name: String,
323    pub targets: Vec<IndexTarget>,
324    pub subitems: Vec<IndexSubItem>,
325    pub category_key: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct IndexSubItem {
330    pub name: String,
331    pub targets: Vec<IndexTarget>,
332}
333
334/// A diagnostic `create_index` raises, located at a whole document
335/// (`location=docname`, which renders as the source path with no line).
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub struct IndexMessage {
338    pub docname: String,
339    pub message: String,
340}
341
342impl IndexMessage {
343    /// Render as the `[index]`-categorized build warning Sphinx logs.
344    pub fn into_warning(self, path: &Path) -> BuildWarning {
345        BuildWarning::new(path.to_path_buf(), None, self.message, WarningType::Other)
346            .with_category(Some(INDEX_CATEGORY.to_string()))
347    }
348}
349
350/// `IndexEntries.create_index(builder, group_entries=True)`
351/// (`environment/adapters/indexentries.py:54-197`).
352///
353/// `rel_uri(docname)` is `builder.get_relative_uri('genindex', docname)`;
354/// `None` is Sphinx's `NoUri`, which keeps the entry but drops its link.
355pub fn create_index(
356    env: &BuildEnvironment,
357    rel_uri: &dyn Fn(&str) -> Option<String>,
358    messages: &mut Vec<IndexMessage>,
359) -> Vec<IndexGroup> {
360    let mut new = Working::default();
361
362    // Sphinx iterates `index_domain.entries` — a dict, so in the order the
363    // documents were *read*. For a full build that is `sorted(docnames)`
364    // (`Builder.read`), which is the order this `BTreeMap` gives.
365    //
366    // Deliberate divergence on incremental rebuilds: sphinx re-reads only
367    // the outdated documents, and their entries move to the end of the
368    // dict, so its genindex depends on which documents happened to be
369    // stale. This map stays docname-sorted, so an incremental rebuild
370    // produces the *same* index a cold build would — the invariant
371    // `touching_one_document_re_reads_only_it_and_the_environment_still_matches_a_cold_build`
372    // (tests/env_differential.rs) enforces. The only thing the order
373    // decides is sub-entry ties, which
374    // `sub_entries_that_fold_alike_keep_their_insertion_order` pins.
375    for (docname, entries) in &env.index_entries {
376        let rel = rel_uri(docname);
377        for entry in entries {
378            let uri = rel.as_ref().map(|rel| format!("{rel}#{}", entry.target_id));
379            if let Err(message) = add(&mut new, entry, uri.as_deref()) {
380                messages.push(IndexMessage {
381                    docname: docname.clone(),
382                    message,
383                });
384            }
385        }
386    }
387
388    for bucket in &mut new.buckets {
389        bucket.targets.sort_by_cached_key(target_sort_key);
390        for (_, targets, _) in &mut bucket.sub_items {
391            targets.sort_by_cached_key(target_sort_key);
392        }
393    }
394
395    let mut new_list = new.buckets;
396    new_list
397        .sort_by_cached_key(|bucket| entry_sort_key(&bucket.key, bucket.category_key.as_deref()));
398
399    group_entries(&mut new_list);
400
401    let mut grouped: Vec<IndexGroup> = Vec::new();
402    for bucket in new_list {
403        let group = group_of(&bucket.key, bucket.category_key.as_deref());
404        let mut subitems: Vec<IndexSubItem> = bucket
405            .sub_items
406            .into_iter()
407            .map(|(name, targets, _)| IndexSubItem { name, targets })
408            .collect();
409        subitems.sort_by_cached_key(|item| sub_entry_sort_key(&item.name));
410        let entry = IndexEntry {
411            name: bucket.key,
412            targets: bucket.targets,
413            subitems,
414            category_key: bucket.category_key,
415        };
416        match grouped.last_mut() {
417            Some(last) if last.group == group => last.entries.push(entry),
418            _ => grouped.push(IndexGroup {
419                group,
420                entries: vec![entry],
421            }),
422        }
423    }
424    grouped
425}
426
427/// `new`: an insertion-ordered `word -> (targets, sub_items, category_key)`
428/// map. The order is load-bearing only for `sub_items`, whose sort is
429/// stable and can tie (two sub-entries that lowercase to the same string).
430#[derive(Default)]
431struct Working {
432    buckets: Vec<Bucket>,
433    index: HashMap<String, usize>,
434}
435
436struct Bucket {
437    key: String,
438    targets: Vec<IndexTarget>,
439    /// `(subword, targets, category_key)`, in insertion order.
440    sub_items: Vec<(String, Vec<IndexTarget>, Option<String>)>,
441    sub_index: HashMap<String, usize>,
442    category_key: Option<String>,
443}
444
445impl Working {
446    /// `_add_entry` (`:200-215`). Both `setdefault`s keep the category key
447    /// of the *first* entry that created the word (or subword).
448    fn add_entry(
449        &mut self,
450        word: &str,
451        subword: &str,
452        main: Option<&str>,
453        link: Option<&str>,
454        key: Option<&str>,
455    ) {
456        let at = match self.index.get(word) {
457            Some(&at) => at,
458            None => {
459                let at = self.buckets.len();
460                self.buckets.push(Bucket {
461                    key: word.to_string(),
462                    targets: Vec::new(),
463                    sub_items: Vec::new(),
464                    sub_index: HashMap::new(),
465                    category_key: key.map(str::to_string),
466                });
467                self.index.insert(word.to_string(), at);
468                at
469            }
470        };
471        let bucket = &mut self.buckets[at];
472        let targets = if subword.is_empty() {
473            &mut bucket.targets
474        } else {
475            let sub_at = sub_item_slot(bucket, subword, key);
476            &mut bucket.sub_items[sub_at].1
477        };
478        // `if link:` — an empty uri is falsy in Python, and so is `NoUri`'s
479        // `False`, which is what a `see`/`seealso` sub-entry passes.
480        if let Some(link) = link.filter(|link| !link.is_empty()) {
481            targets.push((main.unwrap_or_default().to_string(), link.to_string()));
482        }
483    }
484}
485
486/// `entry[1].setdefault(subword, ([], key))`, as an index into
487/// `bucket.sub_items`.
488fn sub_item_slot(bucket: &mut Bucket, subword: &str, key: Option<&str>) -> usize {
489    match bucket.sub_index.get(subword) {
490        Some(&at) => at,
491        None => {
492            let at = bucket.sub_items.len();
493            bucket
494                .sub_items
495                .push((subword.to_string(), Vec::new(), key.map(str::to_string)));
496            bucket.sub_index.insert(subword.to_string(), at);
497            at
498        }
499    }
500}
501
502/// One collected entry, split and added (`:72-146`).
503fn add(new: &mut Working, entry: &IndexEntryRecord, uri: Option<&str>) -> Result<(), String> {
504    let main = if entry.main { "main" } else { "" };
505    let key = entry.category_key.as_deref();
506    match entry.entry_type.as_str() {
507        "single" => {
508            let (word, subword) = match split_into(2, "single", &entry.value) {
509                Ok(parts) => (parts[0].clone(), parts[1].clone()),
510                Err(_) => (
511                    split_into(1, "single", &entry.value)?.remove(0),
512                    String::new(),
513                ),
514            };
515            new.add_entry(&word, &subword, Some(main), uri, key);
516        }
517        "pair" => {
518            let parts = split_into(2, "pair", &entry.value)?;
519            new.add_entry(&parts[0], &parts[1], Some(main), uri, key);
520            new.add_entry(&parts[1], &parts[0], Some(main), uri, key);
521        }
522        "triple" => {
523            let parts = split_into(3, "triple", &entry.value)?;
524            let (first, second, third) = (&parts[0], &parts[1], &parts[2]);
525            new.add_entry(first, &format!("{second} {third}"), Some(main), uri, key);
526            new.add_entry(second, &format!("{third}, {first}"), Some(main), uri, key);
527            new.add_entry(third, &format!("{first} {second}"), Some(main), uri, key);
528        }
529        // Both `see` and `seealso` split with the type name `'see'`, and
530        // both add a *linkless* sub-entry (`link=False`, `main=None`).
531        "see" => {
532            let parts = split_into(2, "see", &entry.value)?;
533            new.add_entry(&parts[0], &format!("see {}", parts[1]), None, None, key);
534        }
535        "seealso" => {
536            let parts = split_into(2, "see", &entry.value)?;
537            new.add_entry(
538                &parts[0],
539                &format!("see also {}", parts[1]),
540                None,
541                None,
542                key,
543            );
544        }
545        other => return Err(format!("unknown index entry type {}", py_repr(Some(other)))),
546    }
547    Ok(())
548}
549
550/// `_key_func_0` (`:218-221`): main entries first, then by uri.
551fn target_sort_key(target: &IndexTarget) -> (bool, String) {
552    (target.0.is_empty(), target.1.clone())
553}
554
555/// `_key_func_1` (`:224-241`): `((group, lc_key), raw_key)`, where a
556/// *truthy* category key replaces the entry name before folding.
557fn entry_sort_key(key: &str, category_key: Option<&str>) -> (u8, String, String) {
558    let folded = match category_key {
559        Some(category) if !category.is_empty() => category,
560        _ => key,
561    };
562    let lc_key = fold(folded);
563    let first = lc_key.chars().next();
564    let group = if !first.is_some_and(py_isalpha) && !lc_key.starts_with('_') {
565        0 // symbols come first
566    } else {
567        1
568    };
569    (group, lc_key, key.to_string())
570}
571
572/// `_key_func_2` (`:244-250`): sub-entries sort by the same folded key,
573/// with alphabetic (and `_`) keys pushed behind the symbols by a `chr(127)`
574/// prefix.
575fn sub_entry_sort_key(name: &str) -> String {
576    let key = fold(name);
577    if key.chars().next().is_some_and(py_isalpha) || key.starts_with('_') {
578        format!("\u{7f}{key}")
579    } else {
580        key
581    }
582}
583
584/// `_group_by_func` (`:253-267`). Note the asymmetry with
585/// [`entry_sort_key`]: grouping honours a category key that is merely *not
586/// None*, sorting only a truthy one.
587fn group_of(key: &str, category_key: Option<&str>) -> String {
588    if let Some(category) = category_key {
589        return category.to_string();
590    }
591    let key = key.strip_prefix(RTL_MARK).unwrap_or(key);
592    // `key[0]` — Sphinx indexes blindly; an entry name is never empty (every
593    // `_split_into` part is non-empty), but a name that is nothing but the
594    // RTL mark would be after the strip above.
595    let Some(first) = key.chars().next() else {
596        return "Symbols".to_string();
597    };
598    let Some(decomposed) = first.nfd().next() else {
599        return "Symbols".to_string();
600    };
601    let letter: String = decomposed.to_uppercase().collect();
602    if (!letter.is_empty() && letter.chars().all(py_isalpha)) || letter == "_" {
603        letter
604    } else {
605        "Symbols".to_string()
606    }
607}
608
609/// `unicodedata.normalize('NFD', key.lower()).removeprefix('‏')` —
610/// lowercase *first*, then decompose, then drop a leading RTL mark.
611fn fold(key: &str) -> String {
612    let lowered: String = key.to_lowercase().nfd().collect();
613    match lowered.strip_prefix(RTL_MARK) {
614        Some(rest) => rest.to_string(),
615        None => lowered,
616    }
617}
618
619/// Python's `str.isalpha()` for one character — see the module docs for the
620/// `Nl`/`Other_Alphabetic` divergence this approximation carries.
621fn py_isalpha(c: char) -> bool {
622    c.is_alphabetic()
623}
624
625/// The `group_entries` fixup (`:155-183`): consecutive entries that share a
626/// `name (parenthesized)` prefix collapse, the later ones becoming
627/// sub-entries of the first under their parenthesized part.
628fn group_entries(new_list: &mut Vec<Bucket>) {
629    let mut old_key = String::new();
630    // The index of the bucket whose `sub_items` is Sphinx's `old_sub_items`.
631    // `None` is its initial value, a scratch dict owned by nothing: writes
632    // to it are discarded, exactly as they are in Sphinx.
633    let mut old_index: Option<usize> = None;
634    let mut i = 0;
635    while i < new_list.len() {
636        // "cannot move if it has sub_items; structure gets too complex"
637        if new_list[i].sub_items.is_empty() {
638            match fixre_match(&new_list[i].key) {
639                Some((prefix, parenthesized)) => {
640                    if old_key == prefix {
641                        let moved = new_list.remove(i);
642                        if let Some(old) = old_index {
643                            let bucket = &mut new_list[old];
644                            let at = sub_item_slot(
645                                bucket,
646                                &parenthesized,
647                                moved.category_key.as_deref(),
648                            );
649                            bucket.sub_items[at].1.extend(moved.targets);
650                        }
651                        continue;
652                    }
653                    old_key = prefix;
654                }
655                None => old_key = new_list[i].key.clone(),
656            }
657        }
658        old_index = Some(i);
659        i += 1;
660    }
661}
662
663/// `re.match(r'(.*) ([(][^()]*[)])', key)` — anchored at the start, with a
664/// greedy first group, and with `.` not crossing a newline.
665fn fixre_match(key: &str) -> Option<(String, String)> {
666    // `(.*)` cannot cross a newline, so neither the prefix nor the ` (`
667    // separator can live past the first one.
668    let limit = key.find('\n').unwrap_or(key.len());
669    let bytes = key.as_bytes();
670    // Greedy: the rightmost viable split wins.
671    for open in (0..limit.saturating_sub(1)).rev() {
672        if bytes[open] != b' ' || bytes[open + 1] != b'(' {
673            continue;
674        }
675        let rest = &key[open + 2..];
676        let Some(close) = rest.find(['(', ')']) else {
677            continue;
678        };
679        if rest.as_bytes()[close] != b')' {
680            continue;
681        }
682        return Some((
683            key[..open].to_string(),
684            key[open + 1..open + 3 + close].to_string(),
685        ));
686    }
687    None
688}
689
690/// The finished index as JSON, in the oracle fixture's `genindex` shape.
691/// Infallible: every field of these structs is a plain string, option or
692/// vector.
693pub fn snapshot(groups: &[IndexGroup]) -> serde_json::Value {
694    serde_json::to_value(groups).unwrap_or(serde_json::Value::Null)
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use crate::rst::block::index_entry_tuple;
701
702    fn record(
703        entry_type: &str,
704        value: &str,
705        target_id: &str,
706        main: bool,
707        category_key: Option<&str>,
708    ) -> IndexEntryRecord {
709        IndexEntryRecord {
710            entry_type: entry_type.to_string(),
711            value: value.to_string(),
712            target_id: target_id.to_string(),
713            main,
714            category_key: category_key.map(str::to_string),
715        }
716    }
717
718    fn index_of(entries: &[(&str, Vec<IndexEntryRecord>)]) -> (Vec<IndexGroup>, Vec<IndexMessage>) {
719        let mut env = BuildEnvironment::default();
720        for (docname, records) in entries {
721            env.index_entries
722                .insert((*docname).to_string(), records.clone());
723        }
724        let mut messages = Vec::new();
725        let groups = create_index(&env, &|_| Some(String::new()), &mut messages);
726        (groups, messages)
727    }
728
729    /// The parse half must be the exact inverse of the render half, for
730    /// every character class the render escapes: quotes, backslashes and
731    /// the whitespace escapes.
732    #[test]
733    fn every_rendered_tuple_parses_back_to_the_entry_it_came_from() {
734        let cases = [
735            ("single", "Alpha", "index-0", "", None),
736            ("pair", "bread; butter", "index-1", "main", None),
737            ("single", "it's a term", "id", "", Some("K")),
738            ("single", "say \"hi\"", "id", "", None),
739            ("single", "both ' and \"", "id", "", None),
740            ("single", "back\\slash", "id", "", None),
741            ("single", "back\\ slash", "id", "", None),
742            ("single", "tab\there", "id", "", None),
743            ("single", "new\nline", "id", "", None),
744            ("single", "carriage\rreturn", "id", "", None),
745            ("single", "", "id", "", Some("")),
746            ("single", "(None, 'not a tuple')", "id", "", None),
747        ];
748        for (entry_type, value, target_id, main, key) in cases {
749            let rendered = index_entry_tuple(entry_type, value, target_id, main, key);
750            let parsed = parse_index_entries(std::slice::from_ref(&rendered), "a");
751            assert_eq!(
752                parsed,
753                vec![record(entry_type, value, target_id, !main.is_empty(), key)],
754                "round trip failed for {rendered:?}"
755            );
756        }
757    }
758
759    /// A doctree cached before `entries` became a list attribute holds a
760    /// `Str` there. The harvest must read it as "no entries" — never as one
761    /// entry whose value is the whole blob — and must not manufacture a
762    /// *build* warning for it: the diagnosis is a `log::warn!` line, which
763    /// is deliberately outside the sphinx-fidelity warning stream the
764    /// environment oracle diffs. (The log line itself is not asserted:
765    /// capturing `log` output needs a process-global logger, which is racy
766    /// under the parallel test harness.)
767    #[test]
768    fn a_stale_string_shaped_entries_attribute_harvests_nothing() {
769        let mut index = Node::elem(INDEX, crate::doctree::Span::ZERO);
770        index.set(
771            "entries",
772            AttrValue::Str("('single',\\ 'Alpha',\\ 'index-0',\\ '',\\ None)".to_string()),
773        );
774        let mut root = Node::elem(crate::doctree::kinds::DOCUMENT, crate::doctree::Span::ZERO);
775        root.children.push(index);
776        let mut doctree = Doctree {
777            root,
778            sources: vec!["<document>".to_string()],
779        };
780
781        let mut env = BuildEnvironment::default();
782        let mut warnings = Vec::new();
783        process_doc(
784            &mut env,
785            "a",
786            &mut doctree,
787            Path::new("a.rst"),
788            &mut warnings,
789        );
790
791        assert_eq!(env.index_entries["a"], Vec::new());
792        assert!(warnings.is_empty(), "{warnings:?}");
793        // The node itself stays: nothing was *rejected*, only unreadable.
794        assert_eq!(doctree.root.children.len(), 1);
795    }
796
797    #[test]
798    fn a_malformed_entries_item_is_dropped_not_panicked_on() {
799        assert!(parse_index_entries(&["('single', 'x'".to_string()], "a").is_empty());
800        assert!(
801            parse_index_entries(&["('a', 'b', 'c', 'd', 'e', 'f')".to_string()], "a").is_empty()
802        );
803        assert!(parse_index_entries(&[String::new()], "a").is_empty());
804    }
805
806    /// `_split_into` rejects a value with too few parts *and* one with an
807    /// empty part, and the message is the one Sphinx logs.
808    #[test]
809    fn split_index_msg_texts_match_sphinx() {
810        assert_eq!(
811            split_index_msg("pair", "lonely"),
812            Err("invalid pair index entry 'lonely'".to_string())
813        );
814        assert_eq!(
815            split_index_msg("pair", "a; "),
816            Err("invalid pair index entry 'a; '".to_string())
817        );
818        assert_eq!(
819            split_index_msg("triple", "a; b"),
820            Err("invalid triple index entry 'a; b'".to_string())
821        );
822        // `see`/`seealso` both report the type name `see`.
823        assert_eq!(
824            split_index_msg("seealso", "lonely"),
825            Err("invalid see index entry 'lonely'".to_string())
826        );
827        assert_eq!(
828            split_index_msg("bogus", "x"),
829            Err("invalid bogus index entry 'x'".to_string())
830        );
831        // A `single` entry falls back to the one-part split.
832        assert_eq!(
833            split_index_msg("single", "Alpha"),
834            Ok(vec!["Alpha".to_string()])
835        );
836        assert_eq!(
837            split_index_msg("single", "Alpha; Beta"),
838            Ok(vec!["Alpha".to_string(), "Beta".to_string()])
839        );
840        // Only the first `n-1` semicolons split: the rest ride the last part.
841        assert_eq!(
842            split_index_msg("pair", "a; b; c"),
843            Ok(vec!["a".to_string(), "b; c".to_string()])
844        );
845    }
846
847    /// The oracle corpus never produces an `unknown index entry type`, so
848    /// its text is pinned here instead. Verified against sphinx 9.1.0's
849    /// `__('unknown index entry type %r')` with `entry_type='bogus'`.
850    #[test]
851    fn an_unknown_entry_type_warns_with_the_sphinx_text() {
852        let (groups, messages) = index_of(&[("a", vec![record("bogus", "x", "id", false, None)])]);
853        assert!(groups.is_empty());
854        assert_eq!(
855            messages,
856            vec![IndexMessage {
857                docname: "a".to_string(),
858                message: "unknown index entry type 'bogus'".to_string(),
859            }]
860        );
861    }
862
863    /// A `create_index` `ValueError` is logged with `str(err)` — the same
864    /// text `split_index_msg` raises, which is what the collection half
865    /// warns with too. (Reaching it needs an entry that passed collection
866    /// and fails here: only `see`/`seealso` differ, and they do not — so
867    /// this is exercised through a directly-seeded environment.)
868    #[test]
869    fn an_invalid_value_warns_with_the_value_error_text() {
870        let (_, messages) = index_of(&[("a", vec![record("pair", "lonely", "id", false, None)])]);
871        assert_eq!(
872            messages,
873            vec![IndexMessage {
874                docname: "a".to_string(),
875                message: "invalid pair index entry 'lonely'".to_string(),
876            }]
877        );
878    }
879
880    /// `NoUri` keeps the entry and drops its link.
881    #[test]
882    fn a_document_with_no_uri_contributes_a_linkless_entry() {
883        let mut env = BuildEnvironment::default();
884        env.index_entries.insert(
885            "a".to_string(),
886            vec![record("single", "Alpha", "id", false, None)],
887        );
888        let groups = create_index(&env, &|_| None, &mut Vec::new());
889        assert_eq!(groups.len(), 1);
890        assert!(groups[0].entries[0].targets.is_empty());
891    }
892
893    /// Main entries sort ahead of the rest, then by uri.
894    #[test]
895    fn main_targets_come_first() {
896        let (groups, _) = index_of(&[(
897            "a",
898            vec![
899                record("single", "Alpha", "z", false, None),
900                record("single", "Alpha", "b", true, None),
901                record("single", "Alpha", "a", false, None),
902            ],
903        )]);
904        assert_eq!(
905            groups[0].entries[0].targets,
906            vec![
907                ("main".to_string(), "#b".to_string()),
908                (String::new(), "#a".to_string()),
909                (String::new(), "#z".to_string()),
910            ]
911        );
912    }
913
914    /// The `_fixre` fixup: `func() (in module foo)` / `(in module bar)`
915    /// collapse under one `func()` entry.
916    #[test]
917    fn consecutive_parenthesized_entries_collapse_into_subitems() {
918        let (groups, _) = index_of(&[(
919            "a",
920            vec![
921                record("single", "func() (in module foo)", "f1", false, None),
922                record("single", "func() (in module bar)", "f2", false, None),
923            ],
924        )]);
925        assert_eq!(groups.len(), 1);
926        assert_eq!(groups[0].entries.len(), 1);
927        let entry = &groups[0].entries[0];
928        // The *first* of the two survives as the entry; the second becomes
929        // a sub-entry. Sorted, `bar` precedes `foo`, so the survivor here is
930        // `(in module bar)` and `(in module foo)` moves under it.
931        assert_eq!(entry.name, "func() (in module bar)");
932        assert_eq!(
933            entry
934                .subitems
935                .iter()
936                .map(|item| item.name.as_str())
937                .collect::<Vec<_>>(),
938            vec!["(in module foo)"]
939        );
940    }
941
942    /// An entry that already has sub-entries is never moved, and does not
943    /// become the prefix a later entry can attach to.
944    #[test]
945    fn an_entry_with_subitems_is_never_collapsed() {
946        let (groups, _) = index_of(&[(
947            "a",
948            vec![
949                record("pair", "func() (in module foo); detail", "f1", false, None),
950                record("single", "func() (in module goo)", "f2", false, None),
951            ],
952        )]);
953        let names: Vec<&str> = groups
954            .iter()
955            .flat_map(|group| group.entries.iter().map(|entry| entry.name.as_str()))
956            .collect();
957        assert!(
958            names.contains(&"func() (in module foo)") && names.contains(&"func() (in module goo)"),
959            "{names:?}"
960        );
961    }
962
963    /// Sub-entries are sorted by a *folded* key with a stable sort, so two
964    /// that fold alike keep the order they were added in. That is the one
965    /// place [`Working`]'s insertion order is load-bearing: swapping
966    /// `sub_items` for a `BTreeMap` would silently reorder this pair.
967    #[test]
968    fn sub_entries_that_fold_alike_keep_their_insertion_order() {
969        let (groups, _) = index_of(&[(
970            "a",
971            vec![
972                record("pair", "word; Beta", "b1", false, None),
973                record("pair", "word; beta", "b2", false, None),
974            ],
975        )]);
976        let word = groups
977            .iter()
978            .flat_map(|group| &group.entries)
979            .find(|entry| entry.name == "word")
980            .expect("the `word` entry");
981        assert_eq!(
982            word.subitems
983                .iter()
984                .map(|item| item.name.as_str())
985                .collect::<Vec<_>>(),
986            vec!["Beta", "beta"]
987        );
988    }
989
990    #[test]
991    fn fixre_takes_the_rightmost_parenthesized_group() {
992        assert_eq!(
993            fixre_match("a (b) (c)"),
994            Some(("a (b)".to_string(), "(c)".to_string()))
995        );
996        assert_eq!(
997            fixre_match("a (b) c"),
998            Some(("a".to_string(), "(b)".to_string()))
999        );
1000        assert_eq!(fixre_match("plain"), None);
1001        // `[^()]*` cannot span a nested paren, so the *inner* group wins.
1002        assert_eq!(
1003            fixre_match("nested (a (b))"),
1004            Some(("nested (a".to_string(), "(b)".to_string()))
1005        );
1006        assert_eq!(
1007            fixre_match("func() (in module foo)"),
1008            Some(("func()".to_string(), "(in module foo)".to_string()))
1009        );
1010        // `.` never crosses a newline, so a match must live on line one.
1011        assert_eq!(fixre_match("a\nb (c)"), None);
1012    }
1013
1014    /// A category key overrides the group heading (and, when non-empty, the
1015    /// sort key) — the glossary `:sorted:`/classifier path.
1016    #[test]
1017    fn a_category_key_overrides_the_group_and_the_sort_key() {
1018        let (groups, _) = index_of(&[(
1019            "a",
1020            vec![
1021                record("single", "zebra", "z", false, Some("A")),
1022                record("single", "apple", "a", false, None),
1023            ],
1024        )]);
1025        // One heading: `groupby` merges the consecutive `A` keys, whether
1026        // they came from a category key or from the entry's own initial.
1027        assert_eq!(
1028            groups
1029                .iter()
1030                .map(|group| group.group.as_str())
1031                .collect::<Vec<_>>(),
1032            vec!["A"]
1033        );
1034        // `zebra` sorts under its category key `A`, ahead of `apple`.
1035        assert_eq!(groups[0].entries[0].name, "zebra");
1036        assert_eq!(groups[0].entries[1].name, "apple");
1037    }
1038
1039    /// Symbols group first, `_` is its own group, and a letter group is the
1040    /// uppercased first NFD character.
1041    #[test]
1042    fn grouping_follows_the_first_decomposed_character() {
1043        assert_eq!(group_of("42answer", None), "Symbols");
1044        assert_eq!(group_of("_private", None), "_");
1045        assert_eq!(group_of("alpha", None), "A");
1046        assert_eq!(group_of("Ábc", None), "A");
1047        assert_eq!(group_of("--flag", None), "Symbols");
1048        assert_eq!(group_of("x", Some("")), "");
1049    }
1050}