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}