use crate::Renderer;
use std::fmt::{self, Display, Write};
pub type EscapeFn = fn(input: &str, out: &mut String);
pub fn escape_none(input: &str, out: &mut String) {
out.push_str(input);
}
pub fn escape_html(input: &str, out: &mut String) {
let mut last = 0;
for (i, ch) in input.char_indices() {
let replacement = match ch {
'&' => "&",
'<' => "<",
'>' => ">",
'"' => """,
'\'' => "'",
_ => continue,
};
out.push_str(&input[last..i]);
out.push_str(replacement);
last = i + ch.len_utf8();
}
out.push_str(&input[last..]);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Whitespace {
AsWritten,
Pretty,
Minified,
}
impl Default for Whitespace {
fn default() -> Self {
if cfg!(feature = "minify") {
Whitespace::Minified
} else if cfg!(feature = "pretty") {
Whitespace::Pretty
} else {
Whitespace::AsWritten
}
}
}
#[derive(Debug, Clone)]
pub struct StringRenderer {
buf: String,
escape: EscapeFn,
whitespace: Whitespace,
indent: usize,
verbatim: usize,
line_open: bool,
}
const INDENT_WIDTH: usize = 2;
impl StringRenderer {
pub fn with_escape(escape: EscapeFn) -> Self {
StringRenderer {
buf: String::new(),
escape,
whitespace: Whitespace::default(),
indent: 0,
verbatim: 0,
line_open: false,
}
}
pub fn with_whitespace(mut self, whitespace: Whitespace) -> Self {
self.whitespace = whitespace;
self
}
fn write_markup(&mut self, s: &str) {
if self.whitespace == Whitespace::AsWritten || self.verbatim > 0 {
self.buf.push_str(s);
self.line_open = false;
return;
}
const WS: [char; 4] = [' ', '\t', '\r', '\n'];
let mut rest = s;
let mut wrote_separator = false;
if self.line_open {
rest = rest.trim_start_matches(WS);
}
while let Some(nl) = rest.find('\n') {
let head = &rest[..nl];
self.buf.push_str(head.trim_end_matches(WS));
let run_end = nl + rest[nl..].len() - rest[nl..].trim_start_matches(WS).len();
let run = &rest[nl..run_end];
rest = &rest[run_end..];
match self.whitespace {
Whitespace::Pretty => {
self.buf.push('\n');
let own = run.rsplit('\n').next().unwrap_or("");
for _ in 0..self.indent * INDENT_WIDTH {
self.buf.push(' ');
}
self.buf.push_str(own);
}
Whitespace::Minified => self.buf.push(' '),
Whitespace::AsWritten => unreachable!("handled above"),
}
wrote_separator = true;
}
self.buf.push_str(rest);
self.line_open = rest.is_empty() && (wrote_separator || self.line_open);
}
pub fn into_string(self) -> String {
self.buf
}
pub fn as_str(&self) -> &str {
&self.buf
}
}
struct EscapeSink<'a> {
out: &'a mut String,
escape: EscapeFn,
}
impl Write for EscapeSink<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
(self.escape)(s, self.out);
Ok(())
}
}
impl Renderer for StringRenderer {
fn write_raw(&mut self, s: &str) {
self.buf.push_str(s);
self.line_open = false;
}
fn write_text(&mut self, s: &str) {
self.write_markup(s);
}
fn write_escaped(&mut self, value: &dyn Display) {
let mut sink = EscapeSink {
out: &mut self.buf,
escape: self.escape,
};
let _ = write!(sink, "{value}");
self.line_open = false;
}
fn write_display_raw(&mut self, value: &dyn Display) {
let _ = write!(self.buf, "{value}");
self.line_open = false;
}
fn close_line(&mut self, depth: usize) {
if self.whitespace != Whitespace::Pretty || self.verbatim > 0 || !self.line_open {
return;
}
self.buf.truncate(self.buf.trim_end_matches([' ', '\t']).len());
for _ in 0..(self.indent + depth) * INDENT_WIDTH {
self.buf.push(' ');
}
}
fn push_indent(&mut self, levels: usize) {
self.indent += levels;
}
fn pop_indent(&mut self, levels: usize) {
self.indent = self.indent.saturating_sub(levels);
}
fn set_verbatim(&mut self, on: bool) {
if on {
self.verbatim += 1;
} else {
self.verbatim = self.verbatim.saturating_sub(1);
}
}
fn finish(self: Box<Self>) -> String {
self.buf
}
}
macro_rules! builtin_renderer {
($(#[$doc:meta])* $name:ident, $escape:expr) => {
$(#[$doc])*
#[derive(Debug, Clone)]
pub struct $name(StringRenderer);
impl $name {
pub fn new() -> Self {
$name(StringRenderer::with_escape($escape))
}
pub fn with_whitespace(whitespace: Whitespace) -> Self {
$name(StringRenderer::with_escape($escape).with_whitespace(whitespace))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl Renderer for $name {
fn write_raw(&mut self, s: &str) {
self.0.write_raw(s);
}
fn write_text(&mut self, s: &str) {
self.0.write_text(s);
}
fn write_escaped(&mut self, value: &dyn Display) {
self.0.write_escaped(value);
}
fn write_display_raw(&mut self, value: &dyn Display) {
self.0.write_display_raw(value);
}
fn close_line(&mut self, depth: usize) {
self.0.close_line(depth);
}
fn push_indent(&mut self, levels: usize) {
self.0.push_indent(levels);
}
fn pop_indent(&mut self, levels: usize) {
self.0.pop_indent(levels);
}
fn set_verbatim(&mut self, on: bool) {
self.0.set_verbatim(on);
}
fn finish(self: Box<Self>) -> String {
self.0.into_string()
}
}
};
}
builtin_renderer! {
HtmlRenderer, escape_html
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn html_escapes_special_characters() {
let mut out = String::new();
escape_html(r#"<a href="x">Tom & Jerry's</a>"#, &mut out);
assert_eq!(
out,
"<a href="x">Tom & Jerry's</a>"
);
}
#[test]
fn html_passes_plain_text_unchanged() {
let mut out = String::new();
escape_html("no specials here", &mut out);
assert_eq!(out, "no specials here");
}
#[test]
fn html_renderer_escapes_only_write_escaped() {
let mut r = HtmlRenderer::new();
r.write_raw("<b>");
r.write_escaped(&"<script>");
r.write_display_raw(&"</b>");
assert_eq!(Box::new(r).finish(), "<b><script></b>");
}
#[test]
fn no_escape_policy_passes_through() {
let mut r = StringRenderer::with_escape(escape_none);
r.write_escaped(&"<x>");
assert_eq!(Box::new(r).finish(), "<x>");
}
#[test]
fn string_renderer_is_object_safe() {
let mut boxed: Box<dyn Renderer> = Box::new(StringRenderer::with_escape(escape_html));
boxed.write_escaped(&"a<b");
assert_eq!(boxed.finish(), "a<b");
}
fn render(ws: Whitespace, f: impl FnOnce(&mut dyn Renderer)) -> String {
let mut r = HtmlRenderer::with_whitespace(ws);
f(&mut r);
Box::new(r).finish()
}
#[test]
fn as_written_copies_markup_byte_for_byte() {
let out = render(Whitespace::AsWritten, |r| {
r.push_indent(3);
r.write_text("<a>\n <b/>\n</a>");
});
assert_eq!(out, "<a>\n <b/>\n</a>", "the default must stay a memcpy");
}
#[test]
fn pretty_adds_the_call_sites_depth_to_each_line() {
let out = render(Whitespace::Pretty, |r| {
r.push_indent(2);
r.write_text("<a>\n <b/>\n</a>");
});
assert_eq!(out, "<a>\n <b/>\n </a>");
}
#[test]
fn pretty_leaves_the_root_untouched() {
let out = render(Whitespace::Pretty, |r| r.write_text("<a>\n <b/>\n</a>"));
assert_eq!(out, "<a>\n <b/>\n</a>");
}
#[test]
fn popping_returns_to_the_enclosing_depth() {
let out = render(Whitespace::Pretty, |r| {
r.push_indent(1);
r.pop_indent(1);
r.write_text("x\ny");
});
assert_eq!(out, "x\ny");
}
#[test]
fn minified_replaces_each_newline_run_with_the_space_it_renders_as() {
let out = render(Whitespace::Minified, |r| r.write_text("<a>\n\n <b/>\n</a>"));
assert_eq!(out, "<a> <b/> </a>");
}
#[test]
fn minified_never_separates_what_was_adjacent() {
let out = render(Whitespace::Minified, |r| r.write_text("<b>a</b><b>c</b>"));
assert_eq!(out, "<b>a</b><b>c</b>");
}
#[test]
fn a_space_within_a_line_survives_every_policy() {
for ws in [Whitespace::AsWritten, Whitespace::Pretty, Whitespace::Minified] {
let out = render(ws, |r| r.write_text("<b>6 Mbps</b> up"));
assert_eq!(out, "<b>6 Mbps</b> up", "{ws:?}");
}
}
#[test]
fn verbatim_regions_are_left_alone() {
for ws in [Whitespace::Pretty, Whitespace::Minified] {
let out = render(ws, |r| {
r.push_indent(4);
r.set_verbatim(true);
r.write_text("line one\n line two");
r.set_verbatim(false);
});
assert_eq!(out, "line one\n line two", "{ws:?}");
}
}
#[test]
fn verbatim_nests() {
let out = render(Whitespace::Minified, |r| {
r.set_verbatim(true);
r.set_verbatim(true);
r.set_verbatim(false);
r.write_text("a\nb");
});
assert_eq!(out, "a\nb");
}
#[test]
fn escaped_values_are_never_laid_out() {
let out = render(Whitespace::Minified, |r| {
r.push_indent(2);
r.write_escaped(&"one\ntwo");
});
assert_eq!(out, "one\ntwo");
}
#[test]
fn raw_display_output_is_not_laid_out() {
let out = render(Whitespace::Minified, |r| r.write_display_raw(&"<i>a</i>\n <i>b</i>"));
assert_eq!(out, "<i>a</i>\n <i>b</i>");
}
#[test]
fn closing_a_line_re_indents_to_the_elements_own_depth() {
let out = render(Whitespace::Pretty, |r| {
r.write_raw("<a>");
r.write_text("\n ");
r.write_raw("<b></b>");
r.write_text("\n ");
r.close_line(0);
r.write_raw("</a>");
});
assert_eq!(out, "<a>\n <b></b>\n</a>");
}
#[test]
fn closing_a_line_counts_the_call_sites_depth_too() {
let out = render(Whitespace::Pretty, |r| {
r.push_indent(2);
r.write_text("\n ");
r.close_line(1);
r.write_raw("</a>");
});
assert_eq!(out, "\n </a>", "2 pushed + 1 own = 3 levels");
}
#[test]
fn closing_a_line_never_breaks_inline_content() {
let out = render(Whitespace::Pretty, |r| {
r.push_indent(3);
r.write_raw("<span>");
r.write_text("Wi-Fi");
r.close_line(0);
r.write_raw("</span>");
});
assert_eq!(out, "<span>Wi-Fi</span>");
}
#[test]
fn closing_a_line_leaves_minified_output_alone() {
let out = render(Whitespace::Minified, |r| {
r.write_raw("<a>");
r.write_text("\n ");
r.close_line(0);
r.write_raw("</a>");
});
assert_eq!(out, "<a> </a>");
}
#[test]
fn closing_a_line_is_inert_inside_a_verbatim_region() {
let out = render(Whitespace::Pretty, |r| {
r.set_verbatim(true);
r.write_text("\n ");
r.close_line(0);
r.write_raw("</pre>");
});
assert_eq!(out, "\n </pre>");
}
#[test]
fn a_multi_line_attribute_is_never_re_indented() {
let out = render(Whitespace::Pretty, |r| {
r.push_indent(3);
r.write_raw("<p title=\"one\n two\">");
});
assert_eq!(out, "<p title=\"one\n two\">");
}
#[test]
fn minified_leaves_an_attribute_value_alone() {
let out = render(Whitespace::Minified, |r| r.write_raw("<p title=\"one\n two\">"));
assert_eq!(out, "<p title=\"one\n two\">");
}
}