#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderOptions {
pub pretty: bool,
indent: String,
pub xhtml_self_closing: bool,
pub max_depth: Option<usize>,
}
const MAX_INDENT_LEN: usize = 16;
impl Default for RenderOptions {
fn default() -> Self {
Self {
pretty: false,
indent: " ".to_string(),
xhtml_self_closing: false,
max_depth: None,
}
}
}
impl RenderOptions {
#[must_use]
pub fn compact() -> Self {
Self::default()
}
#[must_use]
pub fn pretty() -> Self {
Self {
pretty: true,
..Self::default()
}
}
#[must_use]
pub fn with_indent(mut self, indent: impl AsRef<str>) -> Self {
self.indent = indent
.as_ref()
.chars()
.filter(|c| matches!(c, ' ' | '\t'))
.take(MAX_INDENT_LEN)
.collect();
self
}
#[must_use]
pub fn indent(&self) -> &str {
&self.indent
}
#[must_use]
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = Some(max_depth);
self
}
#[must_use]
pub fn with_xhtml_self_closing(mut self, yes: bool) -> Self {
self.xhtml_self_closing = yes;
self
}
pub(crate) fn write_indent(&self, out: &mut String, depth: usize) {
if depth == 0 || self.indent.is_empty() {
return;
}
out.reserve(self.indent.len() * depth);
for _ in 0..depth {
out.push_str(&self.indent);
}
}
}
pub trait Render {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize);
fn render(&self) -> String {
self.render_with(&RenderOptions::compact())
}
fn render_pretty(&self) -> String {
self.render_with(&RenderOptions::pretty())
}
fn render_with(&self, options: &RenderOptions) -> String {
let mut out = String::with_capacity(1024);
self.write_into(&mut out, options, 0);
out
}
}
impl<T: Render + ?Sized> Render for &T {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
(**self).write_into(out, options, depth);
}
}
impl<T: Render + ?Sized> Render for Box<T> {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
(**self).write_into(out, options, depth);
}
}
impl<T: Render> Render for Option<T> {
fn write_into(&self, out: &mut String, options: &RenderOptions, depth: usize) {
if let Some(inner) = self {
inner.write_into(out, options, depth);
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn an_indent_that_is_not_whitespace_is_dropped() {
for hostile in ["<script>alert(1)</script>", "\n", "x", " <b> ", " "] {
let kept: String = hostile
.chars()
.filter(|c| matches!(c, ' ' | '\t'))
.collect();
let options = RenderOptions::pretty().with_indent(hostile);
assert_eq!(options.indent(), kept, "{hostile:?}");
let rendered = div().child(p().text("x")).render_with(&options);
assert!(
!rendered.contains('<') || rendered.matches('<').count() == 4,
"{rendered}"
);
assert!(!rendered.contains("script"), "{rendered}");
}
assert_eq!(RenderOptions::pretty().with_indent("\t").indent(), "\t");
}
#[test]
fn an_indent_is_capped_at_sixteen_columns() {
let options = RenderOptions::pretty().with_indent(" ".repeat(10_000));
assert_eq!(options.indent().len(), 16);
}
#[test]
fn a_depth_limit_drops_what_is_deeper_in_both_modes() {
let tree = div()
.child(div().child(div().child(p().text("deep"))))
.child(p().text("side"));
let compact = RenderOptions::compact().with_max_depth(2);
assert_eq!(
tree.render_with(&compact),
"<div><div></div><p>side</p></div>"
);
let pretty = RenderOptions::pretty().with_max_depth(2);
assert_eq!(
tree.render_with(&pretty),
"<div>\n <div>\n </div>\n <p>side</p>\n</div>"
);
assert_eq!(
tree.render_with(&RenderOptions::compact().with_max_depth(0)),
""
);
assert_eq!(
tree.render_with(&RenderOptions::compact().with_max_depth(99)),
tree.render()
);
}
#[test]
fn a_depth_limit_bounds_the_quadratic_pretty_output() {
let mut node = p().text("bottom");
for _ in 0..50_000 {
node = div().child(node);
}
let limited = node.render_with(&RenderOptions::pretty().with_max_depth(512));
assert!(
limited.len() < 512 * 2 * (512 * 2 + 16),
"{} bytes",
limited.len()
);
}
use super::*;
use crate::elements::{div, img, p};
#[test]
fn compact_is_the_default() {
let options = RenderOptions::default();
assert!(!options.pretty);
assert_eq!(options.indent, " ");
assert!(!options.xhtml_self_closing);
}
#[test]
fn the_indent_string_is_configurable() {
let options = RenderOptions::pretty().with_indent(" ");
let mut out = String::new();
options.write_indent(&mut out, 2);
assert_eq!(out, " ");
}
#[test]
fn options_have_value_semantics() {
let base = RenderOptions::compact();
let derived = base.clone().with_xhtml_self_closing(true);
assert!(!base.xhtml_self_closing);
assert!(derived.xhtml_self_closing);
}
#[test]
fn pretty_indents_children_by_two_spaces() {
let tree = div().child(p().text("Hi"));
assert_eq!(tree.render_pretty(), "<div>\n <p>Hi</p>\n</div>");
}
#[test]
fn xhtml_self_closing_is_per_call() {
let tag = img().attr("src", "a.png");
assert_eq!(tag.render(), r#"<img src="a.png">"#);
assert_eq!(
tag.render_with(&RenderOptions::compact().with_xhtml_self_closing(true)),
r#"<img src="a.png" />"#
);
assert_eq!(tag.render(), r#"<img src="a.png">"#);
}
#[test]
fn write_into_appends_rather_than_replacing() {
let mut buffer = String::from("<!-- header -->");
div()
.text("x")
.write_into(&mut buffer, &RenderOptions::compact(), 0);
assert_eq!(buffer, "<!-- header --><div>x</div>");
}
#[test]
fn concurrent_renders_do_not_share_state() {
let results: Vec<(usize, String)> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..8)
.map(|index| {
scope.spawn(move || {
let tag = img().attr("src", format!("{index}.png"));
let options =
RenderOptions::compact().with_xhtml_self_closing(index % 2 == 0);
(index, tag.render_with(&options))
})
})
.collect();
handles
.into_iter()
.map(|h| h.join().expect("thread"))
.collect()
});
for (index, rendered) in results {
assert_eq!(
rendered.ends_with(" />"),
index % 2 == 0,
"{index} rendered {rendered:?}"
);
}
}
}