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            for line in text.split('\n') {
35                out.push_str(&indent);
36                out.push_str(line);
37                out.push('\n');
38            }
39        }
40        return;
41    }
42
43    let mut attrs: Vec<(&str, String)> = Vec::new();
44    push_list_attr(&mut attrs, "backrefs", &node.attrs.backrefs);
45    push_list_attr(&mut attrs, "classes", &node.attrs.classes);
46    push_list_attr(&mut attrs, "dupnames", &node.attrs.dupnames);
47    push_list_attr(&mut attrs, "ids", &node.attrs.ids);
48    push_list_attr(&mut attrs, "names", &node.attrs.names);
49    for (key, value) in &node.attrs.extra {
50        let rendered = match value {
51            AttrValue::Int(i) => i.to_string(),
52            AttrValue::Str(s) => s.clone(),
53        };
54        attrs.push((key, rendered));
55    }
56    attrs.sort_by(|a, b| a.0.cmp(b.0));
57
58    out.push_str(&indent);
59    out.push('<');
60    out.push_str(node.kind);
61    for (name, value) in &attrs {
62        out.push(' ');
63        out.push_str(name);
64        out.push_str("=\"");
65        out.push_str(value);
66        out.push('"');
67    }
68    out.push_str(">\n");
69
70    for child in &node.children {
71        write_node(child, depth + 1, out);
72    }
73}
74
75pub fn pformat(node: &Node) -> String {
76    let mut out = String::new();
77    write_node(node, 0, &mut out);
78    out
79}
80
81#[cfg(test)]
82mod tests {
83    use crate::doctree::{kinds, AttrValue, Node, Span};
84
85    #[test]
86    fn pformat_section_with_attrs() {
87        let mut doc = Node::elem(kinds::DOCUMENT, Span::ZERO);
88        doc.set("source", AttrValue::Str("<snippet>".into()));
89        let mut sec = Node::elem(kinds::SECTION, Span::ZERO);
90        sec.attrs.ids.push("title".into());
91        sec.attrs.names.push("title".into());
92        let mut title = Node::elem(kinds::TITLE, Span::ZERO);
93        title.children.push(Node::text_node("Title", Span::ZERO));
94        sec.children.push(title);
95        doc.children.push(sec);
96        assert_eq!(
97            doc.pformat(),
98            "<document source=\"<snippet>\">\n    <section ids=\"title\" names=\"title\">\n        <title>\n            Title\n"
99        );
100    }
101
102    #[test]
103    fn pformat_serial_escapes_spaces_in_list_values() {
104        let mut sec = Node::elem(kinds::SECTION, Span::ZERO);
105        sec.attrs.ids.push("my-section-title".into());
106        sec.attrs.dupnames.push("my section title!".into());
107        assert_eq!(
108            sec.pformat(),
109            "<section dupnames=\"my\\ section\\ title!\" ids=\"my-section-title\">\n"
110        );
111    }
112
113    #[test]
114    fn pformat_serial_escapes_backslashes_before_spaces() {
115        // Probe B: `.. _a\\b:` -> names="a\\\\b" (value `a\b` -> `a\\b`)
116        let mut t = Node::elem(kinds::TARGET, Span::ZERO);
117        t.attrs.names.push("a\\b".into());
118        assert_eq!(t.pformat(), "<target names=\"a\\\\b\">\n");
119    }
120
121    #[test]
122    fn pformat_does_not_xml_escape() {
123        // Probe B: quotes, angle brackets, ampersands print raw.
124        let mut t = Node::elem(kinds::TARGET, Span::ZERO);
125        t.attrs.names.push("a \"quote\" <b> & c".into());
126        t.set("refuri", AttrValue::Str("https://x/?q=1&r=2".into()));
127        assert_eq!(
128            t.pformat(),
129            "<target names=\"a\\ \"quote\"\\ <b>\\ &\\ c\" refuri=\"https://x/?q=1&r=2\">\n"
130        );
131        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
132        p.children
133            .push(Node::text_node("x < y & z > w", Span::ZERO));
134        assert_eq!(p.pformat(), "<paragraph>\n    x < y & z > w\n");
135    }
136
137    #[test]
138    fn pformat_system_message_scalar_attrs_sorted() {
139        let mut m = Node::elem(kinds::SYSTEM_MESSAGE, Span::ZERO);
140        m.set("level", AttrValue::Int(2));
141        m.set("line", AttrValue::Int(3));
142        m.set("source", AttrValue::Str("<snippet>".into()));
143        m.set("type", AttrValue::Str("WARNING".into()));
144        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
145        p.children
146            .push(Node::text_node("Title underline too short.", Span::ZERO));
147        m.children.push(p);
148        assert_eq!(
149            m.pformat(),
150            "<system_message level=\"2\" line=\"3\" source=\"<snippet>\" type=\"WARNING\">\n    <paragraph>\n        Title underline too short.\n"
151        );
152    }
153
154    #[test]
155    fn pformat_list_and_scalar_attrs_interleave_alphabetically() {
156        // backrefs (list) sorts before level/line/... (scalars): one sequence.
157        let mut m = Node::elem(kinds::SYSTEM_MESSAGE, Span::ZERO);
158        m.attrs.backrefs.push("id1".into());
159        m.set("level", AttrValue::Int(1));
160        m.set("line", AttrValue::Int(7));
161        m.set("source", AttrValue::Str("<snippet>".into()));
162        m.set("type", AttrValue::Str("INFO".into()));
163        assert_eq!(
164            m.pformat(),
165            "<system_message backrefs=\"id1\" level=\"1\" line=\"7\" source=\"<snippet>\" type=\"INFO\">\n"
166        );
167    }
168
169    #[test]
170    fn pformat_multiline_text_indents_each_line() {
171        let mut p = Node::elem(kinds::PARAGRAPH, Span::ZERO);
172        p.children.push(Node::text_node("Title\n===", Span::ZERO));
173        assert_eq!(p.pformat(), "<paragraph>\n    Title\n    ===\n");
174    }
175
176    #[test]
177    fn pformat_xml_space_preserve() {
178        let mut lb = Node::elem(kinds::LITERAL_BLOCK, Span::ZERO);
179        lb.set("xml:space", AttrValue::Str("preserve".into()));
180        lb.children.push(Node::text_node("code here", Span::ZERO));
181        assert_eq!(
182            lb.pformat(),
183            "<literal_block xml:space=\"preserve\">\n    code here\n"
184        );
185    }
186
187    #[test]
188    fn pformat_empty_element_prints_bare_tag() {
189        // Probe: empty list_item / comment / transition print as a bare tag line.
190        let li = Node::elem(kinds::LIST_ITEM, Span::ZERO);
191        assert_eq!(li.pformat(), "<list_item>\n");
192        let t = Node::elem(kinds::TRANSITION, Span::ZERO);
193        assert_eq!(t.pformat(), "<transition>\n");
194    }
195}