Skip to main content

sphinx_ultra/env/
numbers.rs

1//! Section and figure numbering: `TocTreeCollector.assign_section_numbers`
2//! and `assign_figure_numbers` (`sphinx/environment/collectors/toctree.py:197-378`),
3//! the two halves of `get_updated_docs` the resolve phase runs once every
4//! document's toc is in the environment.
5//!
6//! - [`assign_section_numbers`] walks `env.tocs` from every `:numbered:`
7//!   toctree, filling `env.toc_secnumbers[docname][anchorname]` and stamping
8//!   a `secnumber` attribute onto the very `reference` nodes
9//!   [`super::toctree::build_toc`] created (plus the document's title node,
10//!   so rellinks can show its number).
11//! - [`assign_figure_numbers`] walks the *doctrees* in document order,
12//!   following toctrees depth-first, and numbers every enumerable node
13//!   (figure/table/captioned code-block) into
14//!   `env.toc_fignumbers[docname][figtype][id]`.
15//!
16//! Both return the docnames whose numbers differ from the previous
17//! environment — Sphinx's `env-get-updated` contribution, which widens the
18//! write set on an incremental build.
19//!
20//! NOT here: the `numref` role's use of these tables (`std` domain
21//! resolution) and `numfig_format` rendering. This module only assigns the
22//! numbers.
23
24use std::borrow::Cow;
25use std::collections::{BTreeMap, BTreeSet};
26
27use crate::doctree::{kinds, AttrValue, Doctree, Node};
28use crate::env::std_domain::PropagatedIds;
29use crate::env::toctree::VIRTUAL_DOC_NAMES;
30use crate::env::BuildEnvironment;
31
32/// How the numbering passes reach a document's doctree.
33///
34/// Sphinx calls `env.get_doctree(docname)`, which unpickles
35/// `doctreedir/<docname>.doctree` on every call. This crate already holds
36/// most doctrees in memory (the read phase's results, including the ones a
37/// warm cache hit loaded from disk), so the loader hands back a [`Cow`]:
38/// borrowed for a doctree the caller already has, owned for one it had to
39/// read. Returning `None` — Sphinx would raise — simply skips the document.
40pub type DoctreeLoader<'a> = dyn Fn(&str) -> Option<Cow<'a, Doctree>> + 'a;
41
42/// One diagnostic from [`assign_section_numbers`].
43///
44/// Sphinx logs it with `location=toctreenode`, which renders as the
45/// containing document's `source:line`. A [`Node`] in this crate carries
46/// byte offsets, not line numbers, so the location is reported as the
47/// containing docname plus the ordinal of the `toctree` node within that
48/// document (document order) — enough for the builder to look the line up
49/// in the document's parse records.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NumberingWarning {
52    /// The document containing the `toctree` node the warning is about.
53    pub docname: String,
54    /// That node's 0-based ordinal among the document's `toctree` nodes, in
55    /// document order.
56    pub toctree_index: usize,
57    /// The message, formatted exactly as Sphinx formats it.
58    pub message: String,
59    /// Sphinx's `type.subtype` category; see
60    /// [`crate::error::BuildWarning::category`].
61    pub category: Option<String>,
62}
63
64/// What [`assign_section_numbers`] produced.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct SectionNumbering {
67    /// Docnames whose `toc_secnumbers` differ from the previous
68    /// environment's (Sphinx's `rewrite_needed`), in assignment order.
69    pub changed: Vec<String>,
70    pub warnings: Vec<NumberingWarning>,
71}
72
73/// Assign a section number to every heading under a `:numbered:` toctree.
74///
75/// Exact port of `assign_section_numbers` (`collectors/toctree.py:197-283`).
76/// `env.toc_secnumbers` is rebuilt from scratch (the old table is only kept
77/// to diff against), every reachable document is numbered exactly once, and
78/// a document a second numbered toctree tries to claim produces warning #20
79/// instead.
80///
81/// The walk mutates `env.tocs` in place: each entry's `reference` node gets
82/// the `secnumber` attribute Sphinx stamps there, which is what makes the
83/// rendered toc show `1.2. Title`.
84pub fn assign_section_numbers<'d>(
85    env: &mut BuildEnvironment,
86    load_doctree: &DoctreeLoader<'d>,
87) -> SectionNumbering {
88    let old = std::mem::take(&mut env.toc_secnumbers);
89    let mut walker = SecnumWalker {
90        env,
91        load_doctree,
92        old,
93        assigned: BTreeSet::new(),
94        numstack: vec![0],
95        out: SectionNumbering::default(),
96    };
97
98    // Sphinx iterates `env.numbered_toctrees`, a Python set; this iterates
99    // the same membership in docname order, which is deterministic (the
100    // numbers themselves are per-toctree, so the order only decides which
101    // of two competing numbered toctrees wins — and that case is exactly
102    // the one warning #20 reports).
103    let containers: Vec<String> = walker.env.numbered_toctrees.iter().cloned().collect();
104    for docname in containers {
105        walker.assigned.insert(docname.clone());
106        let Some(doctree) = (walker.load_doctree)(&docname) else {
107            continue;
108        };
109        for (index, toctree) in toctree_nodes(&doctree.root).into_iter().enumerate() {
110            let depth = numbered_depth(toctree);
111            if depth != 0 {
112                // Every numbered toctree restarts the numbering.
113                walker.numstack = vec![0];
114                walker.walk_toctree(&docname, index, toctree, depth);
115            }
116        }
117    }
118
119    walker.out
120}
121
122struct SecnumWalker<'e, 'l, 'd> {
123    env: &'e mut BuildEnvironment,
124    load_doctree: &'l DoctreeLoader<'d>,
125    /// `env.toc_secnumbers` as it was before this pass, to diff against.
126    old: BTreeMap<String, BTreeMap<String, Vec<u32>>>,
127    assigned: BTreeSet<String>,
128    numstack: Vec<u32>,
129    out: SectionNumbering,
130}
131
132impl SecnumWalker<'_, '_, '_> {
133    /// `_walk_toctree` (`collectors/toctree.py:247-281`).
134    ///
135    /// `location_*` identify the `toctree` node being walked, for warning
136    /// #20's `location=toctreenode`.
137    fn walk_toctree(
138        &mut self,
139        location_docname: &str,
140        location_index: usize,
141        toctree: &Node,
142        depth: i64,
143    ) {
144        if depth == 0 {
145            return;
146        }
147        // Sphinx iterates `toctree['entries']` and skips URL entries and
148        // `'self'`; `includefiles` is exactly that subset of `entries`, in
149        // the same order (`directives/other.py:88-176` appends to both
150        // together and reverses both together).
151        for reference in toctree_includefiles(toctree).to_vec() {
152            if self.assigned.contains(&reference) {
153                self.out.warnings.push(NumberingWarning {
154                    docname: location_docname.to_string(),
155                    toctree_index: location_index,
156                    message: format!(
157                        "{reference} is already assigned section numbers \
158                         (nested numbered toctree?)"
159                    ),
160                    category: Some("toc.secnum".to_string()),
161                });
162                continue;
163            }
164            if !self.env.tocs.contains_key(&reference) {
165                continue;
166            }
167            self.assigned.insert(reference.clone());
168
169            // The walk writes into the toc's own `reference` nodes and into
170            // the document's title node, so both are lifted out of the
171            // environment for the duration and put back afterwards.
172            let mut toc = self
173                .env
174                .tocs
175                .remove(&reference)
176                .expect("presence checked above");
177            let mut titlenode = self.env.titles.remove(&reference);
178            let mut secnums: BTreeMap<String, Vec<u32>> = BTreeMap::new();
179            let mut toctree_index = 0;
180            self.walk_toc(
181                &reference,
182                &mut toctree_index,
183                &mut toc.children,
184                &mut secnums,
185                depth,
186                titlenode.as_mut(),
187            );
188            self.env.tocs.insert(reference.clone(), toc);
189            if let Some(title) = titlenode {
190                self.env.titles.insert(reference.clone(), title);
191            }
192
193            if self.old.get(&reference) != Some(&secnums) {
194                self.out.changed.push(reference.clone());
195            }
196            // Sphinx installs `secnums` in `env.toc_secnumbers` before the
197            // walk and mutates it in place; nothing reads the table during
198            // the walk, so installing the finished map here is equivalent.
199            self.env.toc_secnumbers.insert(reference, secnums);
200        }
201    }
202
203    /// `_walk_toc` (`collectors/toctree.py:206-245`).
204    ///
205    /// `titlenode` is the document's own title, which the first numbered
206    /// entry claims (that is the document itself); every branch that hands
207    /// it down also drops its own copy, which `Option::take` mirrors
208    /// exactly.
209    fn walk_toc(
210        &mut self,
211        docname: &str,
212        toctree_index: &mut usize,
213        children: &mut [Node],
214        secnums: &mut BTreeMap<String, Vec<u32>>,
215        depth: i64,
216        mut titlenode: Option<&mut Node>,
217    ) {
218        for subnode in children.iter_mut() {
219            match subnode.kind {
220                kinds::BULLET_LIST => {
221                    self.numstack.push(0);
222                    self.walk_toc(
223                        docname,
224                        toctree_index,
225                        &mut subnode.children,
226                        secnums,
227                        depth - 1,
228                        titlenode.take(),
229                    );
230                    self.numstack.pop();
231                }
232                // `only` entries are numbered even though the tag filter may
233                // exclude them later: Sphinx accepts the resulting gaps
234                // rather than guessing (`collectors/toctree.py:223-228`).
235                kinds::LIST_ITEM | kinds::ONLY => {
236                    self.walk_toc(
237                        docname,
238                        toctree_index,
239                        &mut subnode.children,
240                        secnums,
241                        depth,
242                        titlenode.take(),
243                    );
244                }
245                kinds::COMPACT_PARAGRAPH => {
246                    // Object-description entries (`desc_signature`) opt out.
247                    if subnode.get("skip_section_number").is_some() {
248                        continue;
249                    }
250                    if let Some(last) = self.numstack.last_mut() {
251                        *last += 1;
252                    }
253                    let number = if depth > 0 {
254                        Some(self.numstack.clone())
255                    } else {
256                        None
257                    };
258                    let Some(reference) = subnode.children.first_mut() else {
259                        continue;
260                    };
261                    let anchorname = match reference.get("anchorname") {
262                        Some(AttrValue::Str(anchor)) => anchor.clone(),
263                        _ => String::new(),
264                    };
265                    secnums.insert(anchorname, number.clone().unwrap_or_default());
266                    reference.set("secnumber", secnumber_attr(number.as_deref()));
267                    if let Some(title) = titlenode.take() {
268                        title.set("secnumber", secnumber_attr(number.as_deref()));
269                    }
270                }
271                kinds::TOCTREE => {
272                    let index = *toctree_index;
273                    *toctree_index += 1;
274                    self.walk_toctree(docname, index, subnode, depth);
275                }
276                _ => {}
277            }
278        }
279    }
280}
281
282/// docutils renders a `None` attribute value as `name="True"` (its
283/// "boolean attribute" convention, `nodes.py:663-664`), which is how a
284/// section past the toctree's `:numbered:` depth prints — Sphinx sets
285/// `reference['secnumber'] = None` there. A real number is a Python list,
286/// which renders space-joined.
287fn secnumber_attr(number: Option<&[u32]>) -> AttrValue {
288    match number {
289        Some(number) => AttrValue::List(number.iter().map(u32::to_string).collect()),
290        None => AttrValue::Str("True".to_string()),
291    }
292}
293
294/// Assign a figure number to every enumerable node reachable from the root
295/// document.
296///
297/// Exact port of `assign_figure_numbers` (`collectors/toctree.py:285-378`).
298/// Note the ordering Sphinx relies on: `env.toc_fignumbers` is cleared
299/// *unconditionally*, but only refilled when `numfig` is on — so turning
300/// `numfig` off drops every figure number (and, quirk included, reports
301/// nothing as changed).
302///
303/// Must run after [`assign_section_numbers`]: figure numbers are scoped by
304/// the section numbers that pass assigns.
305pub fn assign_figure_numbers<'d>(
306    env: &mut BuildEnvironment,
307    numfig: bool,
308    numfig_secnum_depth: u32,
309    load_doctree: &DoctreeLoader<'d>,
310) -> Vec<String> {
311    let old = std::mem::take(&mut env.toc_fignumbers);
312    if !numfig {
313        return Vec::new();
314    }
315
316    let root_doc = env.root_doc.clone();
317    let mut walker = FignumWalker {
318        env,
319        load_doctree,
320        secnum_depth: numfig_secnum_depth as usize,
321        assigned: BTreeSet::new(),
322        counters: BTreeMap::new(),
323    };
324    walker.walk_doc(&root_doc, &[]);
325
326    env.toc_fignumbers
327        .iter()
328        .filter(|(docname, fignums)| old.get(*docname) != Some(*fignums))
329        .map(|(docname, _)| docname.clone())
330        .collect()
331}
332
333struct FignumWalker<'e, 'l, 'd> {
334    env: &'e mut BuildEnvironment,
335    load_doctree: &'l DoctreeLoader<'d>,
336    secnum_depth: usize,
337    assigned: BTreeSet<String>,
338    /// figtype -> (truncated section number -> count so far).
339    counters: BTreeMap<String, BTreeMap<Vec<u32>, u32>>,
340}
341
342impl FignumWalker<'_, '_, '_> {
343    /// `_walk_doc` (`collectors/toctree.py:366-370`).
344    fn walk_doc(&mut self, docname: &str, secnum: &[u32]) {
345        if !self.assigned.insert(docname.to_string()) {
346            return;
347        }
348        // Sphinx's `env.get_doctree` raises for a document with no doctree;
349        // here a document we cannot load is simply not numbered.
350        let Some(doctree) = (self.load_doctree)(docname) else {
351            return;
352        };
353        // `PropagateTargets` has already run by the time Sphinx numbers a
354        // doctree, so a `.. _label:` written above a figure/table/code-block
355        // is part of that node's `ids` here. Our parse layer leaves the
356        // target standing, so the transform is replayed as a lookup.
357        let propagated = PropagatedIds::of(&doctree);
358        self.walk_doctree(docname, &doctree.root.children, secnum, &propagated);
359    }
360
361    /// `_walk_doctree` (`collectors/toctree.py:338-364`).
362    fn walk_doctree(
363        &mut self,
364        docname: &str,
365        children: &[Node],
366        secnum: &[u32],
367        propagated: &PropagatedIds,
368    ) {
369        for subnode in children {
370            // docutils Text nodes are not Elements: Sphinx's isinstance
371            // chain skips them entirely.
372            if subnode.kind == kinds::TEXT {
373                continue;
374            }
375            if subnode.kind == kinds::SECTION {
376                let next = self.section_number(docname, subnode);
377                let inherited = if next.is_empty() { secnum } else { &next };
378                self.walk_doctree(docname, &subnode.children, inherited, propagated);
379            } else if subnode.kind == kinds::TOCTREE {
380                // Document order, depth first: this is what makes figure
381                // numbers follow the reading order of the whole project.
382                // `includefiles` is `entries` minus URL and `self` entries,
383                // which is exactly what Sphinx skips here.
384                for subdocname in toctree_includefiles(subnode).to_vec() {
385                    if VIRTUAL_DOC_NAMES.contains(&subdocname.as_str()) {
386                        continue;
387                    }
388                    self.walk_doc(&subdocname, secnum);
389                }
390            } else {
391                if let Some(figtype) = figtype_of(subnode) {
392                    // `fignode['ids'][0]`, after propagation.
393                    if let Some(figure_id) = propagated.effective_ids(subnode).first() {
394                        let figure_id = figure_id.clone();
395                        self.register_fignumber(docname, secnum, figtype, &figure_id);
396                    }
397                }
398                self.walk_doctree(docname, &subnode.children, secnum, propagated);
399            }
400        }
401    }
402
403    /// `get_section_number` (`collectors/toctree.py:310-318`): a section's
404    /// own number if the toc has one, else the document's own.
405    fn section_number(&self, docname: &str, section: &Node) -> Vec<u32> {
406        let anchorname = format!(
407            "#{}",
408            section.attrs.ids.first().map(String::as_str).unwrap_or("")
409        );
410        let Some(secnumbers) = self.env.toc_secnumbers.get(docname) else {
411            return Vec::new();
412        };
413        secnumbers
414            .get(&anchorname)
415            .or_else(|| secnumbers.get(""))
416            .cloned()
417            .unwrap_or_default()
418    }
419
420    /// `register_fignumber` + `get_next_fignumber`
421    /// (`collectors/toctree.py:320-336`).
422    fn register_fignumber(
423        &mut self,
424        docname: &str,
425        secnum: &[u32],
426        figtype: &str,
427        figure_id: &str,
428    ) {
429        let truncated = &secnum[..secnum.len().min(self.secnum_depth)];
430        let counter = self
431            .counters
432            .entry(figtype.to_string())
433            .or_default()
434            .entry(truncated.to_vec())
435            .or_insert(0);
436        *counter += 1;
437
438        let mut number = truncated.to_vec();
439        number.push(*counter);
440        self.env
441            .toc_fignumbers
442            .entry(docname.to_string())
443            .or_default()
444            .entry(figtype.to_string())
445            .or_default()
446            .insert(figure_id.to_string(), number);
447    }
448}
449
450/// `get_figtype` (`collectors/toctree.py:296-308`) over the two domains
451/// that register enumerable nodes (`Domain.enumerable_nodes` is empty for
452/// every other one, `domains/__init__.py:99`).
453///
454/// Sphinx asks every domain in `env.domains.sorted()` — **alphabetical**,
455/// so `math` is asked before `std` — and takes the first answer, with one
456/// twist: a `StandardDomain` whose `get_numfig_title` comes back empty is
457/// `continue`d past *before* its figtype is considered, which is how an
458/// uncaptioned figure/table/container ends up unnumbered. Only the std
459/// domain gets that treatment, so a labelled equation is numbered with no
460/// caption of any kind.
461fn figtype_of(node: &Node) -> Option<&'static str> {
462    if let Some(figtype) = math_enumerable_node_type(node) {
463        return Some(figtype);
464    }
465    let figtype = std_enumerable_node_type(node);
466    if clean_astext(std_numfig_title(node)?).is_empty() {
467        return None;
468    }
469    figtype
470}
471
472/// `MathDomain.enumerable_nodes` (`domains/math.py:58-60`): a `math_block`
473/// is a `displaymath`, which is where `:eq:` picks an equation's number up
474/// (`domains/math.py:115-121`) when `numfig` and `math_numfig` are both on.
475/// Note there is no `math_numfig` gate *here*: Sphinx numbers display math
476/// whenever `numfig` is on, and only consults `math_numfig` when rendering.
477fn math_enumerable_node_type(node: &Node) -> Option<&'static str> {
478    (node.kind == "math_block").then_some("displaymath")
479}
480
481/// `StandardDomain.get_enumerable_node_type` (`domains/std/__init__.py:1380-1393`)
482/// over `enumerable_nodes` (`:799-803`).
483fn std_enumerable_node_type(node: &Node) -> Option<&'static str> {
484    match node.kind {
485        kinds::SECTION => Some("section"),
486        "figure" => Some("figure"),
487        kinds::TABLE => Some("table"),
488        // A `container` carrying the `literal_block` flag *and* a
489        // `literal_block` child is a captioned code block; every other
490        // container falls through to the plain `enumerable_nodes` lookup,
491        // which also says `code-block` (and is then filtered out by the
492        // caption check, since a bare container has no caption).
493        "container" => Some("code-block"),
494        _ => None,
495    }
496}
497
498/// `StandardDomain.get_numfig_title` (`domains/std/__init__.py:1366-1378`):
499/// the caption (or title) of an enumerable node, or `None` when the node
500/// isn't enumerable at all. Note `section` is deliberately absent from
501/// `enumerable_nodes`, so a section has no numfig title.
502pub(crate) fn std_numfig_title(node: &Node) -> Option<&Node> {
503    if !matches!(node.kind, "figure" | "container" | kinds::TABLE) {
504        return None;
505    }
506    node.children
507        .iter()
508        .find(|child| child.kind == "caption" || child.kind == kinds::TITLE)
509}
510
511/// `sphinx.util.nodes.clean_astext`: the node's text with `raw` subtrees
512/// dropped and image alt text blanked (an `image` contributes no text here
513/// either way).
514pub(crate) fn clean_astext(node: &Node) -> String {
515    fn walk(node: &Node, out: &mut String) {
516        if node.kind == "raw" {
517            return;
518        }
519        match &node.text {
520            Some(text) => out.push_str(text),
521            None => {
522                for child in &node.children {
523                    walk(child, out);
524                }
525            }
526        }
527    }
528    let mut out = String::new();
529    walk(node, &mut out);
530    out
531}
532
533/// `toctreenode.get('numbered', 0)` — the `:numbered:` depth.
534fn numbered_depth(toctree: &Node) -> i64 {
535    match toctree.get("numbered") {
536        Some(AttrValue::Int(depth)) => *depth,
537        _ => 0,
538    }
539}
540
541/// `toctree['includefiles']`: the docname subset of `toctree['entries']`,
542/// in entry order.
543fn toctree_includefiles(toctree: &Node) -> &[String] {
544    match toctree.get("includefiles") {
545        Some(AttrValue::List(files)) => files,
546        _ => &[],
547    }
548}
549
550/// Every `toctree` node in a subtree, in document (pre-order) order —
551/// docutils `findall(addnodes.toctree)`.
552fn toctree_nodes(node: &Node) -> Vec<&Node> {
553    fn walk<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
554        if node.kind == kinds::TOCTREE {
555            out.push(node);
556        }
557        for child in &node.children {
558            walk(child, out);
559        }
560    }
561    let mut out = Vec::new();
562    walk(node, &mut out);
563    out
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::doctree::Span;
570    use crate::env::toctree::{build_toc, document_title, note_toctree, toctree_copies};
571    use crate::rst::{parse_rst, ParseOptions};
572
573    /// Build an environment the way the real merge phase does, from a
574    /// `docname -> rst` corpus, and hand back the doctrees for the loader.
575    fn read(sources: &[(&str, &str)]) -> (BuildEnvironment, BTreeMap<String, Doctree>) {
576        let found: BTreeSet<String> = sources.iter().map(|(name, _)| name.to_string()).collect();
577        let found = std::sync::Arc::new(found);
578
579        let mut env = BuildEnvironment {
580            root_doc: "index".to_string(),
581            ..Default::default()
582        };
583        let mut doctrees = BTreeMap::new();
584        for (docname, body) in sources {
585            let doctree = parse_rst(
586                body,
587                &ParseOptions {
588                    source_path: format!("{docname}.rst"),
589                    sphinx: true,
590                    docname: (*docname).to_string(),
591                    exclude_patterns: Vec::new(),
592                    py: Default::default(),
593                    srcdir: None,
594                    found_docs: Some(std::sync::Arc::clone(&found)),
595                    ..Default::default()
596                },
597            );
598            env.all_docs.insert((*docname).to_string(), 0);
599            env.titles
600                .insert((*docname).to_string(), document_title(&doctree));
601            let (toc, entries) = build_toc(&doctree, docname);
602            for toctree in toctree_copies(&toc) {
603                note_toctree(&mut env, docname, toctree);
604            }
605            env.tocs.insert((*docname).to_string(), toc);
606            env.toc_num_entries.insert((*docname).to_string(), entries);
607            doctrees.insert((*docname).to_string(), doctree);
608        }
609        (env, doctrees)
610    }
611
612    fn loader<'a>(
613        doctrees: &'a BTreeMap<String, Doctree>,
614    ) -> impl Fn(&str) -> Option<Cow<'a, Doctree>> + 'a {
615        move |docname: &str| doctrees.get(docname).map(Cow::Borrowed)
616    }
617
618    fn secnums(env: &BuildEnvironment, docname: &str) -> Vec<(String, Vec<u32>)> {
619        env.toc_secnumbers
620            .get(docname)
621            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
622            .unwrap_or_default()
623    }
624
625    #[test]
626    fn numbered_toctree_numbers_every_reachable_document() {
627        let (mut env, doctrees) = read(&[
628            (
629                "index",
630                "Index\n=====\n\n.. toctree::\n   :numbered:\n\n   a\n   b\n",
631            ),
632            ("a", "A\n=\n\nSub\n---\n\nText.\n"),
633            ("b", "B\n=\n\nLeaf.\n"),
634        ]);
635        let load = loader(&doctrees);
636        let out = assign_section_numbers(&mut env, &load);
637
638        assert_eq!(
639            secnums(&env, "a"),
640            vec![(String::new(), vec![1]), ("#sub".to_string(), vec![1, 1]),]
641        );
642        assert_eq!(secnums(&env, "b"), vec![(String::new(), vec![2])]);
643        // The container itself is never numbered.
644        assert!(!env.toc_secnumbers.contains_key("index"));
645        assert_eq!(out.changed, vec!["a".to_string(), "b".to_string()]);
646        assert!(out.warnings.is_empty());
647    }
648
649    #[test]
650    fn secnumbers_are_stamped_onto_the_toc_references_and_the_title() {
651        let (mut env, doctrees) = read(&[
652            (
653                "index",
654                "Index\n=====\n\n.. toctree::\n   :numbered:\n\n   a\n",
655            ),
656            ("a", "A\n=\n\nSub\n---\n\nText.\n"),
657        ]);
658        let load = loader(&doctrees);
659        assign_section_numbers(&mut env, &load);
660
661        let toc = env.tocs["a"].pformat();
662        assert!(
663            toc.contains(r#"anchorname="" internal="1" refuri="a" secnumber="1""#),
664            "{toc}"
665        );
666        assert!(
667            toc.contains(r##"anchorname="#sub" internal="1" refuri="a" secnumber="1 1""##),
668            "{toc}"
669        );
670        // The document title carries the document's own number, which is
671        // what next/prev/parent rellinks render.
672        assert_eq!(
673            env.titles["a"].get("secnumber"),
674            Some(&AttrValue::List(vec!["1".to_string()]))
675        );
676    }
677
678    #[test]
679    fn entries_below_the_numbered_depth_get_no_number() {
680        let (mut env, doctrees) = read(&[
681            (
682                "index",
683                "Index\n=====\n\n.. toctree::\n   :numbered: 1\n\n   a\n",
684            ),
685            ("a", "A\n=\n\nSub\n---\n\nText.\n"),
686        ]);
687        let load = loader(&doctrees);
688        assign_section_numbers(&mut env, &load);
689
690        assert_eq!(
691            secnums(&env, "a"),
692            vec![(String::new(), vec![1]), ("#sub".to_string(), vec![])]
693        );
694        // docutils prints a None attribute value as `="True"`.
695        assert!(
696            env.tocs["a"]
697                .pformat()
698                .contains(r##"anchorname="#sub" internal="1" refuri="a" secnumber="True""##),
699            "{}",
700            env.tocs["a"].pformat()
701        );
702    }
703
704    #[test]
705    fn a_document_two_numbered_toctrees_claim_warns_once() {
706        let (mut env, doctrees) = read(&[
707            (
708                "index",
709                "Index\n=====\n\n.. toctree::\n   :numbered:\n\n   a\n\n.. toctree::\n   :numbered:\n\n   a\n",
710            ),
711            ("a", "A\n=\n\nText.\n"),
712        ]);
713        let load = loader(&doctrees);
714        let out = assign_section_numbers(&mut env, &load);
715
716        assert_eq!(
717            out.warnings,
718            vec![NumberingWarning {
719                docname: "index".to_string(),
720                toctree_index: 1,
721                message: "a is already assigned section numbers (nested numbered toctree?)"
722                    .to_string(),
723                category: Some("toc.secnum".to_string()),
724            }]
725        );
726        // The first toctree still numbered it.
727        assert_eq!(secnums(&env, "a"), vec![(String::new(), vec![1])]);
728    }
729
730    #[test]
731    fn a_document_that_is_not_numbered_keeps_no_secnumbers() {
732        let (mut env, doctrees) = read(&[
733            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
734            ("a", "A\n=\n\nText.\n"),
735        ]);
736        let load = loader(&doctrees);
737        let out = assign_section_numbers(&mut env, &load);
738
739        assert!(env.toc_secnumbers.is_empty());
740        assert!(out.changed.is_empty());
741        assert!(!env.tocs["a"].pformat().contains("secnumber="));
742    }
743
744    #[test]
745    fn unchanged_numbers_are_not_reported_as_changed() {
746        let (mut env, doctrees) = read(&[
747            (
748                "index",
749                "Index\n=====\n\n.. toctree::\n   :numbered:\n\n   a\n",
750            ),
751            ("a", "A\n=\n\nText.\n"),
752        ]);
753        let load = loader(&doctrees);
754        assert_eq!(assign_section_numbers(&mut env, &load).changed, ["a"]);
755        // A second pass over the same environment finds the same numbers.
756        assert!(assign_section_numbers(&mut env, &load).changed.is_empty());
757    }
758
759    #[test]
760    fn figure_numbers_are_scoped_by_the_secnum_depth() {
761        let sources = [
762            (
763                "index",
764                "Index\n=====\n\n.. toctree::\n   :numbered:\n\n   a\n   b\n",
765            ),
766            (
767                "a",
768                "A\n=\n\n.. figure:: one.png\n   :name: fig-one\n\n   One.\n",
769            ),
770            (
771                "b",
772                "B\n=\n\n.. figure:: two.png\n   :name: fig-two\n\n   Two.\n",
773            ),
774        ];
775        // depth 1: figures are numbered per top-level document number.
776        let (mut env, doctrees) = read(&sources);
777        let load = loader(&doctrees);
778        assign_section_numbers(&mut env, &load);
779        assign_figure_numbers(&mut env, true, 1, &load);
780        assert_eq!(env.toc_fignumbers["a"]["figure"]["fig-one"], vec![1, 1]);
781        assert_eq!(env.toc_fignumbers["b"]["figure"]["fig-two"], vec![2, 1]);
782
783        // depth 0: one global counter.
784        let (mut env, doctrees) = read(&sources);
785        let load = loader(&doctrees);
786        assign_section_numbers(&mut env, &load);
787        assign_figure_numbers(&mut env, true, 0, &load);
788        assert_eq!(env.toc_fignumbers["a"]["figure"]["fig-one"], vec![1]);
789        assert_eq!(env.toc_fignumbers["b"]["figure"]["fig-two"], vec![2]);
790    }
791
792    #[test]
793    fn uncaptioned_enumerables_are_skipped() {
794        let (mut env, doctrees) = read(&[
795            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
796            (
797                "a",
798                "A\n=\n\n.. figure:: none.png\n   :name: fig-bare\n\n.. figure:: cap.png\n   :name: fig-cap\n\n   Caption.\n",
799            ),
800        ]);
801        let load = loader(&doctrees);
802        assign_figure_numbers(&mut env, true, 1, &load);
803
804        let figures = &env.toc_fignumbers["a"]["figure"];
805        assert!(!figures.contains_key("fig-bare"), "{figures:?}");
806        assert_eq!(figures["fig-cap"], vec![1]);
807    }
808
809    #[test]
810    fn numfig_off_clears_every_figure_number() {
811        let (mut env, doctrees) = read(&[
812            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
813            (
814                "a",
815                "A\n=\n\n.. figure:: cap.png\n   :name: fig-cap\n\n   Caption.\n",
816            ),
817        ]);
818        let load = loader(&doctrees);
819        assign_figure_numbers(&mut env, true, 1, &load);
820        assert!(!env.toc_fignumbers.is_empty());
821
822        // Sphinx resets the table before checking `numfig`, so turning the
823        // option off drops the numbers assigned under it.
824        assert!(assign_figure_numbers(&mut env, false, 1, &load).is_empty());
825        assert!(env.toc_fignumbers.is_empty());
826    }
827
828    #[test]
829    fn a_document_reached_twice_is_numbered_once() {
830        let (mut env, doctrees) = read(&[
831            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n   b\n"),
832            (
833                "a",
834                "A\n=\n\n.. figure:: cap.png\n   :name: fig-a\n\n   Caption.\n\n.. toctree::\n\n   b\n",
835            ),
836            (
837                "b",
838                "B\n=\n\n.. figure:: cap.png\n   :name: fig-b\n\n   Caption.\n",
839            ),
840        ]);
841        let load = loader(&doctrees);
842        assign_figure_numbers(&mut env, true, 1, &load);
843
844        // `b` is reached from `a` first (depth-first), then skipped when
845        // `index`'s own toctree gets to it: one number, not two.
846        assert_eq!(env.toc_fignumbers["a"]["figure"]["fig-a"], vec![1]);
847        assert_eq!(env.toc_fignumbers["b"]["figure"]["fig-b"], vec![2]);
848    }
849
850    #[test]
851    fn tables_and_captioned_code_blocks_get_their_own_counters() {
852        let (mut env, doctrees) = read(&[
853            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
854            (
855                "a",
856                "A\n=\n\n.. list-table:: The Table\n   :name: tab-a\n\n   * - x\n\n.. code-block:: python\n   :name: code-a\n   :caption: The Listing\n\n   x = 1\n",
857            ),
858        ]);
859        let load = loader(&doctrees);
860        assign_figure_numbers(&mut env, true, 1, &load);
861
862        assert_eq!(env.toc_fignumbers["a"]["table"]["tab-a"], vec![1]);
863        assert_eq!(env.toc_fignumbers["a"]["code-block"]["code-a"], vec![1]);
864    }
865
866    /// The classic docutils spelling — a `.. _label:` on its own line above
867    /// the directive — is as much a label as the `:name:` option, because
868    /// `PropagateTargets` moves the target's ids onto the node before
869    /// numbering runs. The number is filed under the propagated id, which is
870    /// what `get_fignumber` then reads back.
871    #[test]
872    fn a_label_written_above_an_enumerable_node_numbers_it() {
873        let (mut env, doctrees) = read(&[
874            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
875            (
876                "a",
877                "A\n=\n\n.. figure:: cap.png\n   :name: fig-named\n\n   Caption.\n\n\
878                 .. _fig-labelled:\n\n.. figure:: cap.png\n\n   Another caption.\n\n\
879                 .. _tab-labelled:\n\n.. list-table:: The Table\n\n   * - x\n\n\
880                 .. _code-labelled:\n\n.. code-block:: python\n   :caption: The Listing\n\n   x = 1\n",
881            ),
882        ]);
883        let load = loader(&doctrees);
884        assign_figure_numbers(&mut env, true, 1, &load);
885
886        let figures = &env.toc_fignumbers["a"]["figure"];
887        assert_eq!(figures["fig-named"], vec![1]);
888        assert_eq!(
889            figures["fig-labelled"],
890            vec![2],
891            "a labelled figure still advances the counter: {figures:?}"
892        );
893        assert_eq!(env.toc_fignumbers["a"]["table"]["tab-labelled"], vec![1]);
894        assert_eq!(
895            env.toc_fignumbers["a"]["code-block"]["code-labelled"],
896            vec![1]
897        );
898    }
899
900    /// The `math` domain also registers an enumerable node, and sorts
901    /// *before* `std`: a labelled equation is numbered as `displaymath`
902    /// with no caption requirement, while an unlabelled one has no ids and
903    /// is therefore never registered.
904    #[test]
905    fn labelled_display_math_is_numbered_as_displaymath() {
906        let (mut env, doctrees) = read(&[
907            ("index", "Index\n=====\n\n.. toctree::\n\n   a\n"),
908            (
909                "a",
910                "A\n=\n\n.. math::\n   :label: eq-one\n\n   x = 1\n\n.. math::\n\n   y = 2\n",
911            ),
912        ]);
913        let load = loader(&doctrees);
914        assign_figure_numbers(&mut env, true, 1, &load);
915
916        let equations = &env.toc_fignumbers["a"]["displaymath"];
917        assert_eq!(equations["equation-eq-one"], vec![1]);
918        assert_eq!(equations.len(), 1, "{equations:?}");
919    }
920
921    #[test]
922    fn a_missing_doctree_is_skipped_rather_than_panicking() {
923        let mut env = BuildEnvironment {
924            root_doc: "index".to_string(),
925            ..Default::default()
926        };
927        env.numbered_toctrees.insert("index".to_string());
928        let empty: BTreeMap<String, Doctree> = BTreeMap::new();
929        let load = loader(&empty);
930
931        assert!(assign_section_numbers(&mut env, &load).changed.is_empty());
932        assert!(assign_figure_numbers(&mut env, true, 1, &load).is_empty());
933    }
934
935    #[test]
936    fn secnumber_attribute_renders_the_way_docutils_renders_it() {
937        let mut node = Node::elem(kinds::REFERENCE, Span::ZERO);
938        node.set("secnumber", secnumber_attr(Some(&[1, 2, 3])));
939        assert_eq!(node.pformat(), "<reference secnumber=\"1 2 3\">\n");
940
941        let mut node = Node::elem(kinds::REFERENCE, Span::ZERO);
942        node.set("secnumber", secnumber_attr(None));
943        assert_eq!(node.pformat(), "<reference secnumber=\"True\">\n");
944    }
945}