Skip to main content

sphinx_ultra/doctree/
pformat.rs

1//! Pseudo-XML emitter, byte-identical to docutils `document.pformat()`.
2//!
3//! Rules (probed against docutils 0.22.4, see the wave-1 probe notes):
4//! - Element line: 4-space indent per depth, `<kind attrs>`, no closing tags.
5//! - Attributes: the five list attributes (backrefs/classes/dupnames/ids/
6//!   names, empty ones suppressed) merge with scalar attributes into ONE
7//!   alphabetically-sorted sequence. Scalars always print.
8//! - NO XML escaping anywhere. List-attribute items go through docutils
9//!   `serial_escape` (`\` -> `\\`, then ` ` -> `\ `) and join with a space.
10//! - Text nodes: each line of the text on its own line at child indent.
11//! - Every emitted line ends with `\n`.
12
13use super::{kinds, AttrValue, Node};
14
15fn serial_escape(s: &str) -> String {
16    s.replace('\\', "\\\\").replace(' ', "\\ ")
17}
18
19fn push_list_attr(out: &mut Vec<(&str, String)>, name: &'static str, values: &[String]) {
20    if !values.is_empty() {
21        let joined = values
22            .iter()
23            .map(|v| serial_escape(v))
24            .collect::<Vec<_>>()
25            .join(" ");
26        out.push((name, joined));
27    }
28}
29
30fn write_node(node: &Node, depth: usize, out: &mut String) {
31    let indent = "    ".repeat(depth);
32    if node.kind == kinds::TEXT {
33        if let Some(text) = &node.text {
34            // `Text.pformat`: `lines = [indent*level + v for v in
35            // self.astext().splitlines()]`, then `if not lines: return ''`.
36            // It is Python's FULL `splitlines()` — `\v`, `\f`, `\x1c`-`\x1e`,
37            // U+0085, U+2028 and U+2029 all break a line, which matters for
38            // the raw file bytes `include :literal:` puts in a Text node —
39            // and an EMPTY Text contributes nothing at all, not an
40            // indent-only line.
41            for line in crate::utils::py_splitlines(text) {
42                out.push_str(&indent);
43                out.push_str(line);
44                out.push('\n');
45            }
46        }
47        return;
48    }
49
50    let mut attrs: Vec<(&str, String)> = Vec::new();
51    push_list_attr(&mut attrs, "backrefs", &node.attrs.backrefs);
52    push_list_attr(&mut attrs, "classes", &node.attrs.classes);
53    push_list_attr(&mut attrs, "dupnames", &node.attrs.dupnames);
54    push_list_attr(&mut attrs, "ids", &node.attrs.ids);
55    push_list_attr(&mut attrs, "names", &node.attrs.names);
56    for (key, value) in &node.attrs.extra {
57        let rendered = match value {
58            AttrValue::Int(i) => i.to_string(),
59            AttrValue::Str(s) => s.clone(),
60            // docutils `Element.starttag`: a list value renders as
61            // `' '.join(serial_escape('%s' % v) for v in value)` — the same
62            // treatment the five universal list attributes get above, and
63            // an empty list renders as an empty value (only the *universal*
64            // list attributes are suppressed when empty, via
65            // `is_not_default`/`list_attributes`).
66            AttrValue::List(items) => items
67                .iter()
68                .map(|v| serial_escape(v))
69                .collect::<Vec<_>>()
70                .join(" "),
71        };
72        attrs.push((key, rendered));
73    }
74    attrs.sort_by(|a, b| a.0.cmp(b.0));
75
76    out.push_str(&indent);
77    out.push('<');
78    out.push_str(node.kind);
79    for (name, value) in &attrs {
80        out.push(' ');
81        out.push_str(name);
82        out.push_str("=\"");
83        out.push_str(value);
84        out.push('"');
85    }
86    out.push_str(">\n");
87
88    for child in &node.children {
89        write_node(child, depth + 1, out);
90    }
91}
92
93pub fn pformat(node: &Node) -> String {
94    let mut out = String::new();
95    write_node(node, 0, &mut out);
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use crate::doctree::{kinds, AttrValue, Node, Span};
102
103    #[test]
104    fn pformat_section_with_attrs() {
105        let mut doc = Node::elem(kinds::DOCUMENT, Span::ZERO);
106        doc.set("source", AttrValue::Str("<snippet>".into()));
107        let mut sec = Node::elem(kinds::SECTION, Span::ZERO);
108        sec.attrs.ids.push("title".into());
109        sec.attrs.names.push("title".into());
110        let mut title = Node::elem(kinds::TITLE, Span::ZERO);
111        title.children.push(Node::text_node("Title", Span::ZERO));
112        sec.children.push(title);
113        doc.children.push(sec);
114        assert_eq!(
115            doc.pformat(),
116            "<document source=\"<snippet>\">\n    <section ids=\"title\" names=\"title\">\n        <title>\n            Title\n"
117        );
118    }
119
120    #[test]
121    fn pformat_serial_escapes_spaces_in_list_values() {
122        let mut sec = Node::elem(kinds::SECTION, Span::ZERO);
123        sec.attrs.ids.push("my-section-title".into());
124        sec.attrs.dupnames.push("my section title!".into());
125        assert_eq!(
126            sec.pformat(),
127            "<section dupnames=\"my\\ section\\ title!\" ids=\"my-section-title\">\n"
128        );
129    }
130
131    #[test]
132    fn pformat_serial_escapes_backslashes_before_spaces() {
133        // Probe B: `.. _a\\b:` -> names="a\\\\b" (value `a\b` -> `a\\b`)
134        let mut t = Node::elem(kinds::TARGET, Span::ZERO);
135        t.attrs.names.push("a\\b".into());
136        assert_eq!(t.pformat(), "<target names=\"a\\\\b\">\n");
137    }
138
139    #[test]
140    fn pformat_does_not_xml_escape() {
141        // Probe B: quotes, angle brackets, ampersands print raw.
142        let mut t = Node::elem(kinds::TARGET, Span::ZERO);
143        t.attrs.names.push("a \"quote\" <b> & c".into());
144        t.set("refuri", AttrValue::Str("https://x/?q=1&r=2".into()));
145        assert_eq!(
146            t.pformat(),
147            "<target names=\"a\\ \"quote\"\\ <b>\\ &\\ c\" refuri=\"https://x/?q=1&r=2\">\n"
148        );
149        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
150        p.children
151            .push(Node::text_node("x < y & z > w", Span::ZERO));
152        assert_eq!(p.pformat(), "<paragraph>\n    x < y & z > w\n");
153    }
154
155    #[test]
156    fn pformat_system_message_scalar_attrs_sorted() {
157        let mut m = Node::elem(kinds::SYSTEM_MESSAGE, Span::ZERO);
158        m.set("level", AttrValue::Int(2));
159        m.set("line", AttrValue::Int(3));
160        m.set("source", AttrValue::Str("<snippet>".into()));
161        m.set("type", AttrValue::Str("WARNING".into()));
162        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
163        p.children
164            .push(Node::text_node("Title underline too short.", Span::ZERO));
165        m.children.push(p);
166        assert_eq!(
167            m.pformat(),
168            "<system_message level=\"2\" line=\"3\" source=\"<snippet>\" type=\"WARNING\">\n    <paragraph>\n        Title underline too short.\n"
169        );
170    }
171
172    #[test]
173    fn pformat_list_and_scalar_attrs_interleave_alphabetically() {
174        // backrefs (list) sorts before level/line/... (scalars): one sequence.
175        let mut m = Node::elem(kinds::SYSTEM_MESSAGE, Span::ZERO);
176        m.attrs.backrefs.push("id1".into());
177        m.set("level", AttrValue::Int(1));
178        m.set("line", AttrValue::Int(7));
179        m.set("source", AttrValue::Str("<snippet>".into()));
180        m.set("type", AttrValue::Str("INFO".into()));
181        assert_eq!(
182            m.pformat(),
183            "<system_message backrefs=\"id1\" level=\"1\" line=\"7\" source=\"<snippet>\" type=\"INFO\">\n"
184        );
185    }
186
187    #[test]
188    fn pformat_multiline_text_indents_each_line() {
189        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
190        p.children.push(Node::text_node("Title\n===", Span::ZERO));
191        assert_eq!(p.pformat(), "<paragraph>\n    Title\n    ===\n");
192    }
193
194    #[test]
195    fn pformat_xml_space_preserve() {
196        let mut lb = Node::elem(kinds::LITERAL_BLOCK, Span::ZERO);
197        lb.set("xml:space", AttrValue::Str("preserve".into()));
198        lb.children.push(Node::text_node("code here", Span::ZERO));
199        assert_eq!(
200            lb.pformat(),
201            "<literal_block xml:space=\"preserve\">\n    code here\n"
202        );
203    }
204
205    #[test]
206    fn pformat_list_attr_joins_serial_escaped_items() {
207        // sphinx toctree[entries]/[includefiles]: list values join with a
208        // space after serial_escape, and an empty list still prints.
209        let mut t = Node::elem(kinds::TOCTREE, Span::ZERO);
210        t.set(
211            "entries",
212            AttrValue::List(vec!["(None, 'a')".into(), "('T x', 'b')".into()]),
213        );
214        t.set("includefiles", AttrValue::List(vec![]));
215        assert_eq!(
216            t.pformat(),
217            "<toctree entries=\"(None,\\ 'a') ('T\\ x',\\ 'b')\" includefiles=\"\">\n"
218        );
219    }
220
221    #[test]
222    fn pformat_empty_element_prints_bare_tag() {
223        // Probe: empty list_item / comment / transition print as a bare tag line.
224        let li = Node::elem(kinds::LIST_ITEM, Span::ZERO);
225        assert_eq!(li.pformat(), "<list_item>\n");
226        let t = Node::elem(kinds::TRANSITION, Span::ZERO);
227        assert_eq!(t.pformat(), "<transition>\n");
228    }
229    /// `Text.pformat` splits on Python's FULL `splitlines()` boundary set
230    /// and drops an empty Text node entirely — reachable since wave 4.5,
231    /// because `include :literal:` puts raw file bytes into a Text node
232    /// without routing them through `string2lines` (which would have
233    /// turned `\v`/`\f` into spaces).
234    ///
235    // oracle (docutils 0.22.4, scratchpad A/pf.py):
236    //   literal_block('', '', Text('a'), Text(''), Text('p1\fp2')).pformat()
237    //     == '<literal_block xml:space="preserve">\n    a\n    p1\n    p2\n'
238    //   literal_block('', '', Text('a\x0bb\x85c d')).pformat()
239    //     == '<literal_block xml:space="preserve">\n    a\n    b\n    c d\n'
240    //   paragraph('', '', Text('x y'), Text('t\rs'), Text('k\x1cj')).pformat()
241    //     == '<paragraph>\n    x y\n    t\n    s\n    k\n    j\n'
242    #[test]
243    fn pformat_text_uses_python_splitlines_and_skips_empty_text() {
244        let mut lb = Node::elem(kinds::LITERAL_BLOCK, Span::ZERO);
245        lb.set("xml:space", AttrValue::Str("preserve".into()));
246        lb.children.push(Node::text_node("a", Span::ZERO));
247        lb.children.push(Node::text_node("", Span::ZERO));
248        lb.children.push(Node::text_node("p1\u{c}p2", Span::ZERO));
249        assert_eq!(
250            lb.pformat(),
251            "<literal_block xml:space=\"preserve\">\n    a\n    p1\n    p2\n"
252        );
253
254        let mut vt = Node::elem(kinds::LITERAL_BLOCK, Span::ZERO);
255        vt.set("xml:space", AttrValue::Str("preserve".into()));
256        vt.children
257            .push(Node::text_node("a\u{b}b\u{85}c d", Span::ZERO));
258        assert_eq!(
259            vt.pformat(),
260            "<literal_block xml:space=\"preserve\">\n    a\n    b\n    c d\n"
261        );
262
263        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
264        p.children.push(Node::text_node("x y", Span::ZERO));
265        p.children.push(Node::text_node("t\rs", Span::ZERO));
266        p.children.push(Node::text_node("k\u{1c}j", Span::ZERO));
267        assert_eq!(
268            p.pformat(),
269            "<paragraph>\n    x y\n    t\n    s\n    k\n    j\n"
270        );
271    }
272
273    /// A trailing newline still yields no extra line, and a Text holding
274    /// only a newline still yields one (indent-only) line.
275    ///
276    // oracle: literal_block('', '', Text('one\ntwo\n'), Text('\n')).pformat()
277    //   == '<literal_block xml:space="preserve">\n    one\n    two\n    \n'
278    #[test]
279    fn pformat_text_trailing_newline_rules() {
280        let mut lb = Node::elem(kinds::LITERAL_BLOCK, Span::ZERO);
281        lb.set("xml:space", AttrValue::Str("preserve".into()));
282        lb.children.push(Node::text_node("one\ntwo\n", Span::ZERO));
283        lb.children.push(Node::text_node("\n", Span::ZERO));
284        assert_eq!(
285            lb.pformat(),
286            "<literal_block xml:space=\"preserve\">\n    one\n    two\n    \n"
287        );
288    }
289}