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 byte-offset [`Span`]
10//! into the original source (docutils itself only keeps `(source, line)`).
11//! Spans are line-granular in wave 1; the wave-2 inline parser refines them.
12
13pub mod ids;
14pub mod kinds;
15pub mod messages;
16pub mod pformat;
17
18/// Byte-offset range into a source file. `source` indexes a per-doctree
19/// source table; wave 1 always uses source 0 (`include` arrives in wave 3).
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct Span {
22    pub source: u16,
23    pub start: u32,
24    pub end: u32,
25}
26
27impl Span {
28    pub const ZERO: Span = Span {
29        source: 0,
30        start: 0,
31        end: 0,
32    };
33}
34
35/// Scalar attribute value. docutils attribute dicts hold ints and strings for
36/// everything wave 1 emits; list-valued attributes live in [`Attrs`]' typed
37/// fields instead.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum AttrValue {
40    Int(i64),
41    Str(String),
42}
43
44/// docutils' universal list attributes (`basic_attributes` + `backrefs`) as
45/// typed fields, plus an open, name-sorted list for everything else
46/// (`refuri`, `enumtype`, `level`, …). `pformat` merges both sets and prints
47/// all pairs in one alphabetical sequence, exactly like docutils `attlist()`.
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct Attrs {
50    pub ids: Vec<String>,
51    pub names: Vec<String>,
52    pub dupnames: Vec<String>,
53    pub classes: Vec<String>,
54    pub backrefs: Vec<String>,
55    /// Kept sorted by key; use [`Node::set`] to maintain the invariant.
56    pub extra: Vec<(&'static str, AttrValue)>,
57}
58
59/// One doctree node. Element nodes have `text == None`; text leaves have
60/// `kind == kinds::TEXT`, `Some(text)`, and no children or attributes.
61#[derive(Debug, Clone, PartialEq)]
62pub struct Node {
63    pub kind: &'static str,
64    pub span: Span,
65    pub text: Option<String>,
66    pub attrs: Attrs,
67    pub children: Vec<Node>,
68}
69
70impl Node {
71    pub fn elem(kind: &'static str, span: Span) -> Node {
72        Node {
73            kind,
74            span,
75            text: None,
76            attrs: Attrs::default(),
77            children: Vec::new(),
78        }
79    }
80
81    pub fn text_node(s: impl Into<String>, span: Span) -> Node {
82        Node {
83            kind: kinds::TEXT,
84            span,
85            text: Some(s.into()),
86            attrs: Attrs::default(),
87            children: Vec::new(),
88        }
89    }
90
91    /// Set a scalar attribute, keeping `attrs.extra` sorted by key and
92    /// overwriting any existing value for the same key.
93    pub fn set(&mut self, key: &'static str, value: AttrValue) {
94        match self.attrs.extra.binary_search_by(|(k, _)| k.cmp(&key)) {
95            Ok(i) => self.attrs.extra[i].1 = value,
96            Err(i) => self.attrs.extra.insert(i, (key, value)),
97        }
98    }
99
100    pub fn get(&self, key: &'static str) -> Option<&AttrValue> {
101        self.attrs
102            .extra
103            .binary_search_by(|(k, _)| k.cmp(&key))
104            .ok()
105            .map(|i| &self.attrs.extra[i].1)
106    }
107
108    /// Concatenated text of all text descendants.
109    ///
110    /// Wave-1 simplification of docutils `Node.astext()`: children join with
111    /// `""` (docutils joins with a per-element `child_text_separator`, which
112    /// only matters for elements wave 1 never calls `astext` on — revisit in
113    /// wave 2 when inline nodes need `" "` and table cells need `"\n\n"`).
114    pub fn astext(&self) -> String {
115        match &self.text {
116            Some(t) => t.clone(),
117            None => self.children.iter().map(Node::astext).collect(),
118        }
119    }
120
121    /// Byte-parity pseudo-XML rendering (docutils `document.pformat()`).
122    pub fn pformat(&self) -> String {
123        pformat::pformat(self)
124    }
125}
126
127/// One parsed document. `root.kind == kinds::DOCUMENT`.
128#[derive(Debug, Clone, PartialEq)]
129pub struct Doctree {
130    pub root: Node,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn elem_constructs_with_kind_and_span() {
139        let n = Node::elem(
140            kinds::PARAGRAPH,
141            Span {
142                source: 0,
143                start: 0,
144                end: 10,
145            },
146        );
147        assert_eq!(n.kind, "paragraph");
148        assert!(n.text.is_none());
149        assert!(n.children.is_empty());
150    }
151
152    #[test]
153    fn text_node_holds_text() {
154        let t = Node::text_node(
155            "hello",
156            Span {
157                source: 0,
158                start: 0,
159                end: 5,
160            },
161        );
162        assert_eq!(t.kind, kinds::TEXT);
163        assert_eq!(t.text.as_deref(), Some("hello"));
164    }
165
166    #[test]
167    fn set_keeps_extra_sorted_and_get_finds() {
168        let mut n = Node::elem(kinds::TARGET, Span::ZERO);
169        n.set("refuri", AttrValue::Str("https://x/".into()));
170        n.set("anonymous", AttrValue::Int(1));
171        assert_eq!(n.attrs.extra[0].0, "anonymous");
172        assert_eq!(n.get("refuri"), Some(&AttrValue::Str("https://x/".into())));
173        n.set("refuri", AttrValue::Str("https://y/".into()));
174        assert_eq!(n.attrs.extra.len(), 2);
175        assert_eq!(n.get("refuri"), Some(&AttrValue::Str("https://y/".into())));
176    }
177
178    #[test]
179    fn astext_joins_text_descendants() {
180        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
181        p.children
182            .push(Node::text_node("line one\nline two", Span::ZERO));
183        assert_eq!(p.astext(), "line one\nline two");
184    }
185}