Skip to main content

winged_rust/core/
render.rs

1//! Rendering options and the [`Render`] trait.
2//!
3//! Ports `core/RenderOptions.swift` and the render half of `core/HTMLTag.swift`.
4//!
5//! `WINGED_RUST_SPEC.md` §2 claims Winged-Swift has a `protocol HTMLRenderable`. It does
6//! not — the only protocol in that library is `Layout`. The real design is a class
7//! hierarchy whose single overridable primitive is
8//! `write(into:options:indentLevel:)`, and that is what [`Render::write_into`] ports.
9//! Everything else is a provided method on top of it, so the whole tree renders into one
10//! buffer with one allocation.
11
12/// How a node tree is turned into markup.
13///
14/// A value passed into the render call, never global state — two threads can render the
15/// same tree with different settings at the same time. Winged-Swift's deprecated
16/// process-wide `HTMLTag.xhtmlSelfClosing` switch is deliberately not ported.
17///
18/// # Examples
19/// ```
20/// use winged_rust::core::RenderOptions;
21/// let compact = RenderOptions::compact();
22/// let pretty = RenderOptions::pretty();
23/// let four_spaces = RenderOptions::pretty().with_indent("    ");
24/// # let _ = (compact, pretty, four_spaces);
25/// ```
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RenderOptions {
28    /// When true, children go on their own lines and are indented.
29    pub pretty: bool,
30    /// One indentation level. Only used when `pretty` is true. Defaults to two spaces.
31    pub indent: String,
32    /// When true, void elements close with ` />` instead of `>`.
33    pub xhtml_self_closing: bool,
34}
35
36impl Default for RenderOptions {
37    fn default() -> Self {
38        Self {
39            pretty: false,
40            indent: "  ".to_string(),
41            xhtml_self_closing: false,
42        }
43    }
44}
45
46impl RenderOptions {
47    /// Minified output on a single line. The default, and what you should ship.
48    #[must_use]
49    pub fn compact() -> Self {
50        Self::default()
51    }
52
53    /// Indented, human-readable output.
54    #[must_use]
55    pub fn pretty() -> Self {
56        Self {
57            pretty: true,
58            ..Self::default()
59        }
60    }
61
62    /// Sets the indentation string.
63    #[must_use]
64    pub fn with_indent(mut self, indent: impl Into<String>) -> Self {
65        self.indent = indent.into();
66        self
67    }
68
69    /// Closes void elements with ` />` instead of `>`.
70    #[must_use]
71    pub fn with_xhtml_self_closing(mut self, yes: bool) -> Self {
72        self.xhtml_self_closing = yes;
73        self
74    }
75
76    /// Appends `depth` levels of indentation to a buffer.
77    pub(crate) fn write_indent(&self, out: &mut String, depth: usize) {
78        // The early return is not just a micro-optimisation: with an empty indent the loop
79        // below still runs once per level, which is quadratic in depth and costs minutes on
80        // a deeply nested tree while producing nothing.
81        if depth == 0 || self.indent.is_empty() {
82            return;
83        }
84
85        out.reserve(self.indent.len() * depth);
86        for _ in 0..depth {
87            out.push_str(&self.indent);
88        }
89    }
90}
91
92/// Anything that can be written as HTML.
93///
94/// Implement [`write_into`](Render::write_into); the rest comes free.
95///
96/// # Depth
97///
98/// The provided implementations walk an explicit work stack rather than recursing, so
99/// nesting depth costs heap rather than stack and there is no depth at which rendering
100/// aborts. A 100,000-level tree is covered by a test.
101///
102/// Pretty mode still writes one indent string per level on every line, which makes its
103/// *output* quadratic in depth. That is a size to be aware of when depth comes from
104/// untrusted input — see `SECURITY.md` — not a limit on what renders.
105pub trait Render {
106    /// Writes this node and its subtree into an existing buffer.
107    ///
108    /// This is the primitive every other method here is built on. Writing into a shared
109    /// buffer avoids allocating an intermediate string per node.
110    fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize);
111
112    /// Renders as a single line of markup.
113    ///
114    /// Note the asymmetry with [`crate::Document::render`], which defaults to *pretty*.
115    /// That difference is in the Swift original and the golden fixtures depend on it.
116    fn render(&self) -> String {
117        self.render_with(&RenderOptions::compact())
118    }
119
120    /// Renders with indentation and line breaks.
121    fn render_pretty(&self) -> String {
122        self.render_with(&RenderOptions::pretty())
123    }
124
125    /// Renders with the given options.
126    fn render_with(&self, options: &RenderOptions) -> String {
127        let mut out = String::with_capacity(1024);
128        self.write_into(&mut out, options, 0);
129        out
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::elements::{div, img, p};
137
138    /// Ports `RenderOptionsTests.compactIsTheDefault`.
139    #[test]
140    fn compact_is_the_default() {
141        let options = RenderOptions::default();
142        assert!(!options.pretty);
143        assert_eq!(options.indent, "  ");
144        assert!(!options.xhtml_self_closing);
145    }
146
147    /// Ports `RenderOptionsTests.indentIsConfigurable`.
148    #[test]
149    fn the_indent_string_is_configurable() {
150        let options = RenderOptions::pretty().with_indent("    ");
151        let mut out = String::new();
152        options.write_indent(&mut out, 2);
153        assert_eq!(out, "        ");
154    }
155
156    /// Ports `RenderOptionsTests.optionsAreValues`. Options are a value: changing a
157    /// copy must not affect the original.
158    #[test]
159    fn options_have_value_semantics() {
160        let base = RenderOptions::compact();
161        let derived = base.clone().with_xhtml_self_closing(true);
162        assert!(!base.xhtml_self_closing);
163        assert!(derived.xhtml_self_closing);
164    }
165
166    /// Ports `RenderOptionsTests.prettyIndentsChildren`.
167    #[test]
168    fn pretty_indents_children_by_two_spaces() {
169        let tree = div().child(p().text("Hi"));
170
171        assert_eq!(tree.render_pretty(), "<div>\n  <p>Hi</p>\n</div>");
172    }
173
174    /// Ports `RenderOptionsTests.xhtmlSelfClosingIsPerCall`.
175    ///
176    /// Winged-Swift used to carry this on a process-wide `HTMLTag.xhtmlSelfClosing` switch
177    /// and is removing it. Here it was never anything but a field on the options value, so
178    /// a render cannot leak into the next one.
179    #[test]
180    fn xhtml_self_closing_is_per_call() {
181        let tag = img().attr("src", "a.png");
182
183        assert_eq!(tag.render(), r#"<img src="a.png">"#);
184        assert_eq!(
185            tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
186            r#"<img src="a.png" />"#
187        );
188        assert_eq!(tag.render(), r#"<img src="a.png">"#);
189    }
190
191    /// Ports `RenderOptionsTests.writeAppendsToAnExistingBuffer`.
192    #[test]
193    fn write_into_appends_rather_than_replacing() {
194        let mut buffer = String::from("<!-- header -->");
195        div()
196            .text("x")
197            .write_into(&mut buffer, &RenderOptions::compact(), 0);
198
199        assert_eq!(buffer, "<!-- header --><div>x</div>");
200    }
201
202    /// Ports `RenderOptionsTests.concurrentRendersDoNotShareState`.
203    ///
204    /// The Swift suite needs this because its tree is reference-typed and its options used
205    /// to be global. Here the tree is `Send + Sync` and the options are a value, so the
206    /// test is a guard against ever reintroducing shared state.
207    #[test]
208    fn concurrent_renders_do_not_share_state() {
209        let results: Vec<(usize, String)> = std::thread::scope(|scope| {
210            let handles: Vec<_> = (0..8)
211                .map(|index| {
212                    scope.spawn(move || {
213                        let tag = img().attr("src", format!("{index}.png"));
214                        let options =
215                            RenderOptions::compact().with_xhtml_self_closing(index % 2 == 0);
216                        (index, tag.render_with(&options))
217                    })
218                })
219                .collect();
220            handles
221                .into_iter()
222                .map(|h| h.join().expect("thread"))
223                .collect()
224        });
225
226        for (index, rendered) in results {
227            assert_eq!(
228                rendered.ends_with(" />"),
229                index % 2 == 0,
230                "{index} rendered {rendered:?}"
231            );
232        }
233    }
234}