Skip to main content

sphinx_ultra/env/
metadata.rs

1//! `env.metadata[docname]` — the document's bibliographic field list, read
2//! as build metadata.
3//!
4//! Port of `MetadataCollector.process_doc`
5//! (`sphinx/environment/collectors/metadata.py:34`). Sphinx reads a
6//! `docinfo` node, which docutils' `DocInfo` transform
7//! (`docutils/transforms/frontmatter.py`) builds from a leading field list;
8//! this crate has no such transform yet, so the port reads that leading
9//! `field_list` directly. The two agree on what a field contributes —
10//! `field_name.astext()` -> `field_body.astext()` — which is all the
11//! consumers need (`orphan`, `nocomments`, `tocdepth`).
12//!
13//! Deliberate omissions:
14//!
15//! * Sphinx pops the `docinfo` node out of the doctree; this leaves the
16//!   `field_list` in place, because removing it is a doctree-shape change
17//!   that belongs with the `DocInfo` transform itself.
18//! * The bibliographic special cases (`authors`, and the `tocdepth`
19//!   int-coercion) are not applied: nothing reads them yet.
20//!
21//! **Known gap — a field list below a promoted document title is not seen.**
22//! docutils runs `DocTitle` before `DocInfo`, so in a single-section
23//! document the section title is promoted to a document `title` and the
24//! field list that follows it still becomes `docinfo`. This crate applies no
25//! transforms at parse time (`crate::rst`, module header: "Transforms
26//! (doctitle promotion, ...) are explicitly NOT applied here"), so such a
27//! document keeps its `section` wrapper and the field list never sits at
28//! document level for this port to find. Consequence: a document whose
29//! `:orphan:` marker follows its title is not exempted from the
30//! `document isn't included in any toctree` warning. `title`/`subtitle` are
31//! already in [`is_pre_bibliographic`], so this closes by itself the moment
32//! doctitle promotion lands — nothing here needs to change.
33
34use std::collections::BTreeMap;
35
36use crate::doctree::{kinds, Doctree, Node};
37
38/// docutils' `PreBibliographic` node classes — what
39/// `first_child_not_matching_class(nodes.PreBibliographic)` skips over
40/// before deciding whether the document opens with bibliographic fields.
41///
42/// The complete set, enumerated from docutils 0.22.4 by walking
43/// `nodes.PreBibliographic`'s element subclasses: the four `Invisible` ones
44/// (`comment`, `substitution_definition`, `target`, `pending`,
45/// `nodes.py:2477-2522`) plus `title`, `subtitle`, `meta`, `decoration`,
46/// `system_message` and `raw` (`nodes.py:1561-2600`).
47///
48/// `raw` is the one of these a document can hit today — `.. raw:: html`
49/// ahead of the field list. The rest are transform output this crate does
50/// not produce yet (see the module header), but skipping them is correct
51/// and free.
52fn is_pre_bibliographic(node: &Node) -> bool {
53    matches!(
54        node.kind,
55        kinds::COMMENT | kinds::TARGET | kinds::SYSTEM_MESSAGE | kinds::TITLE | kinds::SUBTITLE
56    ) || node.kind == "substitution_definition"
57        || node.kind == "pending"
58        || node.kind == "raw"
59        || node.kind == "meta"
60        || node.kind == "decoration"
61}
62
63/// The field list docutils' `DocInfo` transform would turn into the
64/// document's `docinfo`: the first child that is not `PreBibliographic`,
65/// when that child is a field list. Sphinx's `MetadataCollector` pops that
66/// node out of the doctree, so a consumer walking the tree for Sphinx's
67/// view of it (the search index) skips exactly this node.
68pub(crate) fn docinfo_field_list(doctree: &Doctree) -> Option<&Node> {
69    doctree
70        .root
71        .children
72        .iter()
73        .find(|node| !is_pre_bibliographic(node))
74        .filter(|first| first.kind == kinds::FIELD_LIST)
75}
76
77/// The document's metadata: field name -> field body text, empty when the
78/// document does not open with a field list.
79pub fn document_metadata(doctree: &Doctree) -> BTreeMap<String, String> {
80    let mut metadata = BTreeMap::new();
81
82    let Some(first) = docinfo_field_list(doctree) else {
83        return metadata;
84    };
85
86    for field in &first.children {
87        if field.kind != kinds::FIELD {
88            continue;
89        }
90        let name = field
91            .children
92            .iter()
93            .find(|child| child.kind == kinds::FIELD_NAME);
94        let body = field
95            .children
96            .iter()
97            .find(|child| child.kind == kinds::FIELD_BODY);
98        if let Some(name) = name {
99            metadata.insert(name.astext(), body.map(Node::astext).unwrap_or_default());
100        }
101    }
102    metadata
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::rst;
109
110    fn parse(source: &str) -> Doctree {
111        rst::parse_rst(source, &rst::ParseOptions::default())
112    }
113
114    #[test]
115    fn leading_field_list_becomes_metadata() {
116        let metadata = document_metadata(&parse(":orphan:\n\nTitle\n=====\n\nBody.\n"));
117        assert_eq!(metadata.get("orphan").map(String::as_str), Some(""));
118    }
119
120    #[test]
121    fn field_bodies_are_captured() {
122        let metadata = document_metadata(&parse(":tocdepth: 2\n:nocomments:\n\nBody.\n"));
123        assert_eq!(metadata.get("tocdepth").map(String::as_str), Some("2"));
124        assert!(metadata.contains_key("nocomments"));
125    }
126
127    #[test]
128    fn comments_and_targets_do_not_hide_the_field_list() {
129        // docutils skips PreBibliographic nodes before looking for docinfo.
130        let metadata = document_metadata(&parse(".. a comment\n\n:orphan:\n\nBody.\n"));
131        assert!(metadata.contains_key("orphan"), "{metadata:?}");
132    }
133
134    /// `raw` is `PreBibliographic` too (`docutils/nodes.py:2600`), and it is
135    /// the one member of that set a document can produce before any
136    /// transform runs. Verified against docutils 0.22.4: the same source
137    /// yields `<document><docinfo><field classes="orphan">…` — the field
138    /// list is still bibliographic with a `.. raw::` block ahead of it.
139    #[test]
140    fn a_leading_raw_block_does_not_hide_the_field_list() {
141        let metadata = document_metadata(&parse(".. raw:: html\n\n   <hr>\n\n:orphan:\n\nBody.\n"));
142        assert_eq!(metadata.get("orphan").map(String::as_str), Some(""));
143    }
144
145    #[test]
146    fn a_field_list_below_the_first_body_element_is_not_metadata() {
147        let metadata = document_metadata(&parse("Body.\n\n:orphan:\n"));
148        assert!(metadata.is_empty(), "{metadata:?}");
149    }
150
151    #[test]
152    fn a_document_without_fields_has_no_metadata() {
153        assert!(document_metadata(&parse("Title\n=====\n")).is_empty());
154    }
155}