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}