sphinx_ultra/doctree/mod.rs
1//! Typed doctree IR with docutils-equivalent node semantics (M2 wave 1).
2//!
3//! Design (recorded in docs/superpowers/plans/2026-08-07-m2-wave-map.md):
4//! docutils-mirror generic node — node identity and attributes are data, not
5//! Rust types, so the pseudo-XML parity serializer is a direct dump and
6//! docutils transforms/writers port line-by-line. One `Node` struct covers
7//! every element type; `kind` holds the docutils tagname from [`kinds`].
8//!
9//! Source spans are structural: every node carries a [`Span`] with real
10//! `(source, line)` provenance plus the byte range of its text in the
11//! parser's processed source (docutils itself only keeps `(source, line)`).
12//! Spans are line-granular; the inline parser stamps its nodes with the
13//! enclosing text block's span.
14
15pub mod ids;
16mod intern;
17pub mod kinds;
18pub mod messages;
19pub mod pformat;
20
21pub(crate) use intern::intern;
22
23use serde::{Deserialize, Deserializer, Serialize};
24
25/// Source provenance of a node: which source it came from, the 1-based
26/// line docutils would report for it, and the byte range of its text.
27///
28/// `source` indexes the per-doctree source table ([`Doctree::sources`];
29/// 0 is the document itself, included files push further entries), and
30/// `start..end` is a byte range into the parser's *processed* text of that
31/// source (tab-expanded, trailing whitespace stripped — the text the
32/// parser actually consumed).
33///
34/// `line` is 1-based within `source`; 0 means unknown. It carries the
35/// docutils reporting convention the env layer's warnings need: every node
36/// is stamped with the first line of its span, except a `section`, which
37/// is stamped one past that (docutils creates a section only once the
38/// state machine has consumed the title's underline).
39///
40/// `Default` is [`Span::ZERO`]: source 0, line 0 (unknown), empty range.
41#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Span {
43 pub source: u16,
44 pub line: u32,
45 pub start: u32,
46 pub end: u32,
47}
48
49impl Span {
50 pub const ZERO: Span = Span {
51 source: 0,
52 line: 0,
53 start: 0,
54 end: 0,
55 };
56}
57
58/// Attribute value. docutils attribute dicts hold ints and strings for
59/// everything wave 1 emits; docutils' five *universal* list attributes
60/// (`ids`, `names`, ...) live in [`Attrs`]' typed fields instead.
61///
62/// [`AttrValue::List`] covers the element-specific list-valued attributes
63/// docutils renders through the same `serial_escape`-and-join path as the
64/// universal ones (`toctree[entries]`, `toctree[includefiles]`) — storing
65/// them as a list rather than a pre-joined string keeps the escaping in
66/// `pformat` (one implementation, not one per producer) and keeps the items
67/// readable by consumers such as `env::toctree::note_toctree`.
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub enum AttrValue {
70 Int(i64),
71 Str(String),
72 List(Vec<String>),
73}
74
75/// docutils' universal list attributes (`basic_attributes` + `backrefs`) as
76/// typed fields, plus an open, name-sorted list for everything else
77/// (`refuri`, `enumtype`, `level`, …). `pformat` merges both sets and prints
78/// all pairs in one alphabetical sequence, exactly like docutils `attlist()`.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct Attrs {
81 pub ids: Vec<String>,
82 pub names: Vec<String>,
83 pub dupnames: Vec<String>,
84 pub classes: Vec<String>,
85 pub backrefs: Vec<String>,
86 /// Kept sorted by key; use [`Node::set`] to maintain the invariant.
87 #[serde(
88 serialize_with = "intern::serialize_extra",
89 deserialize_with = "intern::deserialize_extra"
90 )]
91 pub extra: Vec<(&'static str, AttrValue)>,
92}
93
94/// One doctree node. Element nodes have `text == None`; text leaves have
95/// `kind == kinds::TEXT`, `Some(text)`, and no children or attributes.
96#[derive(Debug, Clone, PartialEq, Serialize)]
97pub struct Node {
98 #[serde(serialize_with = "intern::serialize_str")]
99 pub kind: &'static str,
100 pub span: Span,
101 pub text: Option<String>,
102 pub attrs: Attrs,
103 pub children: Vec<Node>,
104}
105
106/// Owned mirror of [`Node`] whose only job is to let `#[derive(Deserialize)]`
107/// do the field-by-field work. Deriving `Deserialize` directly on `Node`
108/// hits a serde-derive limitation: a field whose type literally names the
109/// `'static` lifetime (`kind: &'static str`) makes the derived impl require
110/// `'de: 'static` instead of the unconstrained `impl<'de> Deserialize<'de>`
111/// every other caller (including bincode decoding from a borrowed `&[u8]`)
112/// needs. Deserializing into this all-owned shadow and interning `kind`
113/// afterward sidesteps it.
114#[derive(Deserialize)]
115struct NodeShadow {
116 kind: String,
117 span: Span,
118 text: Option<String>,
119 attrs: Attrs,
120 children: Vec<Node>,
121}
122
123impl<'de> Deserialize<'de> for Node {
124 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125 where
126 D: Deserializer<'de>,
127 {
128 let shadow = NodeShadow::deserialize(deserializer)?;
129 let kind = intern(&shadow.kind).map_err(serde::de::Error::custom)?;
130 Ok(Node {
131 kind,
132 span: shadow.span,
133 text: shadow.text,
134 attrs: shadow.attrs,
135 children: shadow.children,
136 })
137 }
138}
139
140impl Node {
141 pub fn elem(kind: &'static str, span: Span) -> Node {
142 Node {
143 kind,
144 span,
145 text: None,
146 attrs: Attrs::default(),
147 children: Vec::new(),
148 }
149 }
150
151 pub fn text_node(s: impl Into<String>, span: Span) -> Node {
152 Node {
153 kind: kinds::TEXT,
154 span,
155 text: Some(s.into()),
156 attrs: Attrs::default(),
157 children: Vec::new(),
158 }
159 }
160
161 /// docutils `Element.copy()`: same kind, span and attributes, but **no
162 /// children** (docutils copies `rawsource` and attributes only;
163 /// `deepcopy` is the one that takes the subtree).
164 pub fn shallow_copy(&self) -> Node {
165 Node {
166 kind: self.kind,
167 span: self.span,
168 text: self.text.clone(),
169 attrs: self.attrs.clone(),
170 children: Vec::new(),
171 }
172 }
173
174 /// Set a scalar attribute, keeping `attrs.extra` sorted by key and
175 /// overwriting any existing value for the same key.
176 pub fn set(&mut self, key: &'static str, value: AttrValue) {
177 match self.attrs.extra.binary_search_by(|(k, _)| k.cmp(&key)) {
178 Ok(i) => self.attrs.extra[i].1 = value,
179 Err(i) => self.attrs.extra.insert(i, (key, value)),
180 }
181 }
182
183 pub fn get(&self, key: &'static str) -> Option<&AttrValue> {
184 self.attrs
185 .extra
186 .binary_search_by(|(k, _)| k.cmp(&key))
187 .ok()
188 .map(|i| &self.attrs.extra[i].1)
189 }
190
191 /// Concatenated text of all text descendants.
192 ///
193 /// Wave-1 simplification of docutils `Node.astext()`: children join with
194 /// `""` (docutils joins with a per-element `child_text_separator`, which
195 /// only matters for elements wave 1 never calls `astext` on — revisit in
196 /// wave 2 when inline nodes need `" "` and table cells need `"\n\n"`).
197 pub fn astext(&self) -> String {
198 match &self.text {
199 Some(t) => t.clone(),
200 None => self.children.iter().map(Node::astext).collect(),
201 }
202 }
203
204 /// Byte-parity pseudo-XML rendering (docutils `document.pformat()`).
205 pub fn pformat(&self) -> String {
206 pformat::pformat(self)
207 }
208}
209
210/// One parsed document. `root.kind == kinds::DOCUMENT`.
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
212pub struct Doctree {
213 pub root: Node,
214 /// The source table `Span.source` indexes: one path per source the
215 /// parse consumed. Entry 0 is the document's own path; sub-parses over
216 /// lifted text (table cells) and, later, included files push further
217 /// entries. `#[serde(default = "default_sources")]` is the standard
218 /// hedge for a field added after data in this shape might already
219 /// exist — but note it only rescues *self-describing* formats (e.g.
220 /// JSON) from a missing field: bincode's wire format has no
221 /// field-presence framing, so a bincode blob that predates this field
222 /// would still fail to decode (`UnexpectedEnd`) rather than fall back
223 /// to the default. No such blob existed before the field did, so that
224 /// gap isn't live today.
225 #[serde(default = "default_sources")]
226 pub sources: Vec<String>,
227}
228
229impl Doctree {
230 /// The `(source path, line)` a warning about a node should report —
231 /// docutils' `(node.source, node.line)`. Line 0 means unknown; a
232 /// `source` the table doesn't know (a foreign or hand-built span)
233 /// falls back to the document's own path.
234 pub fn source_and_line(&self, span: Span) -> (&str, u32) {
235 let path = self
236 .sources
237 .get(span.source as usize)
238 .or_else(|| self.sources.first())
239 .map(String::as_str)
240 .unwrap_or("<document>");
241 (path, span.line)
242 }
243}
244
245fn default_sources() -> Vec<String> {
246 vec!["<document>".to_string()]
247}
248
249/// Encode a doctree to its bincode wire format (config: `standard()`).
250/// Infallible in practice: every field either derives `Serialize` or goes
251/// through the interner's plain string encoding, neither of which can fail.
252pub fn to_bincode(doctree: &Doctree) -> Vec<u8> {
253 bincode::serde::encode_to_vec(doctree, bincode::config::standard())
254 .expect("Doctree encoding is infallible")
255}
256
257/// Decode a doctree previously written by [`to_bincode`]. Also the entry
258/// point for bytes that *weren't* — a corrupted file, or a stale/foreign
259/// blob from a version-skewed cache — which can fail for the usual decode
260/// reasons and additionally once decoding would intern more than
261/// `intern`'s bounded table allows (see `src/doctree/intern.rs`).
262pub fn from_bincode(bytes: &[u8]) -> anyhow::Result<Doctree> {
263 let (doctree, _consumed): (Doctree, usize) =
264 bincode::serde::decode_from_slice(bytes, bincode::config::standard())?;
265 Ok(doctree)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 /// `#[serde(default = "default_sources")]` on `Doctree::sources` only
273 /// rescues a missing field for *self-describing* formats (see the
274 /// field's doc comment) — bincode's positional wire format has no way
275 /// to signal "field absent, use the default" for a trailing struct
276 /// field, so this property can only be demonstrated through JSON here.
277 #[test]
278 fn doctree_deserialize_defaults_sources_when_field_absent_in_json() {
279 let json = r#"{"root":{"kind":"document","span":{"source":0,"line":0,"start":0,"end":0},"text":null,"attrs":{"ids":[],"names":[],"dupnames":[],"classes":[],"backrefs":[],"extra":{}},"children":[]}}"#;
280
281 let restored: Doctree = serde_json::from_str(json).expect("json without sources decodes");
282
283 assert_eq!(restored.sources, vec!["<document>".to_string()]);
284 assert_eq!(restored.root.kind, kinds::DOCUMENT);
285 }
286
287 #[test]
288 fn elem_constructs_with_kind_and_span() {
289 let n = Node::elem(
290 kinds::PARAGRAPH,
291 Span {
292 source: 0,
293 line: 1,
294 start: 0,
295 end: 10,
296 },
297 );
298 assert_eq!(n.kind, "paragraph");
299 assert!(n.text.is_none());
300 assert!(n.children.is_empty());
301 }
302
303 #[test]
304 fn text_node_holds_text() {
305 let t = Node::text_node(
306 "hello",
307 Span {
308 source: 0,
309 line: 1,
310 start: 0,
311 end: 5,
312 },
313 );
314 assert_eq!(t.kind, kinds::TEXT);
315 assert_eq!(t.text.as_deref(), Some("hello"));
316 }
317
318 #[test]
319 fn set_keeps_extra_sorted_and_get_finds() {
320 let mut n = Node::elem(kinds::TARGET, Span::ZERO);
321 n.set("refuri", AttrValue::Str("https://x/".into()));
322 n.set("anonymous", AttrValue::Int(1));
323 assert_eq!(n.attrs.extra[0].0, "anonymous");
324 assert_eq!(n.get("refuri"), Some(&AttrValue::Str("https://x/".into())));
325 n.set("refuri", AttrValue::Str("https://y/".into()));
326 assert_eq!(n.attrs.extra.len(), 2);
327 assert_eq!(n.get("refuri"), Some(&AttrValue::Str("https://y/".into())));
328 }
329
330 #[test]
331 fn source_and_line_reads_the_table_and_falls_back_to_entry_0() {
332 let tree = Doctree {
333 root: Node::elem(kinds::DOCUMENT, Span::ZERO),
334 sources: vec!["a.rst".to_string(), "b.rst".to_string()],
335 };
336 let span = |source, line| Span {
337 source,
338 line,
339 start: 0,
340 end: 0,
341 };
342 assert_eq!(tree.source_and_line(span(0, 3)), ("a.rst", 3));
343 assert_eq!(tree.source_and_line(span(1, 7)), ("b.rst", 7));
344 assert_eq!(
345 tree.source_and_line(span(9, 2)),
346 ("a.rst", 2),
347 "an unknown source id falls back to the document's own path"
348 );
349 }
350
351 #[test]
352 fn astext_joins_text_descendants() {
353 let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
354 p.children
355 .push(Node::text_node("line one\nline two", Span::ZERO));
356 assert_eq!(p.astext(), "line one\nline two");
357 }
358}