Skip to main content

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}