femstache 0.1.13

Embrace the feminine mustache to unlock your templating dreams! Or something, I don't know. :3
Documentation
const HTML_ESCAPE_CHARS: [char; 5] = ['&', '<', '>', '"', '\''];
const HTML_ESCAPE_REPLACEMENTS: [&str; 5] = ["&amp;", "&lt;", "&gt;", "&quot;", "&#39;"];

// TODO: find out if this is actually faster than just iterating char-by-char
fn escape_html_into(s: &str, output: &mut OutputTarget) {
    for segment in s.split_inclusive(|c| HTML_ESCAPE_CHARS.contains(&c)) {
        let mut chars = segment.chars();
        let Some(last) = chars.next_back() else {
            continue;
        };
        let rest = chars.as_str();
        if let Some(index) = HTML_ESCAPE_CHARS.iter().position(|&c| c == last) {
            output.push(rest);
            output.push(HTML_ESCAPE_REPLACEMENTS[index]);
        } else {
            output.push(segment);
        }
    }
}

/// How to escape text that needs to be escaped
#[derive(Debug, Clone, Copy)]
pub enum EscapeMode {
    /// Do not escape
    None,
    /// Use HTML escapes
    Html,
    /// Custom function providing escaping
    Custom(fn(&str, &mut OutputTarget)),
}

/// A target we can output to
#[derive(Debug, Clone)]
pub enum OutputTarget {
    /// Do not output anywhere
    Null,
    /// Output to a [String]
    String(String),
}

impl OutputTarget {
    /// Push a [&str] to the output
    pub fn push(&mut self, s: &str) {
        match self {
            OutputTarget::Null => {}
            OutputTarget::String(o) => o.push_str(s),
        }
    }

    /// Turn this into a [String]
    pub fn into_inner(self) -> String {
        match self {
            OutputTarget::Null => String::new(),
            OutputTarget::String(s) => s,
        }
    }
}

/// Describes where to output the rendered template and how to escape the output
#[derive(Debug, Clone)]
pub struct Output {
    target: OutputTarget,
    escape: EscapeMode,
}

impl Output {
    /// Do not output to anywhere
    pub fn null(escape: EscapeMode) -> Self {
        Self {
            target: OutputTarget::Null,
            escape,
        }
    }

    /// Output to a newly-allocated string
    pub fn string(escape: EscapeMode) -> Self {
        Self {
            target: OutputTarget::String(String::new()),
            escape,
        }
    }

    /// Get the escape mode
    pub fn escape(&self) -> EscapeMode {
        self.escape
    }

    /// Push a [&str] to this output
    pub fn push(&mut self, s: &str) {
        self.target.push(s);
    }

    /// Push a [&str] to this output, and escape it
    pub fn push_escaped(&mut self, s: &str) {
        match self.escape {
            EscapeMode::None => {
                self.target.push(s);
            }
            EscapeMode::Custom(push_escaped) => {
                push_escaped(s, &mut self.target);
            }
            EscapeMode::Html => {
                escape_html_into(s, &mut self.target);
            }
        }
    }

    /// Turn this into its inner [String], this is only useful for string outputs
    pub fn into_inner(self) -> String {
        self.target.into_inner()
    }
}

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    use super::*;

    fn naive_escape_html(s: &str) -> String {
        s.replace('&', "&amp;")
            .replace('<', "&lt;")
            .replace('>', "&gt;")
            .replace('"', "&quot;")
            .replace('\'', "&#39;")
    }

    const MEOW: [char; 4] = ['m', 'e', 'o', 'w'];

    fn is_only_meows(s: &str) -> bool {
        let mut index = 0;
        for c in s.chars() {
            if c != MEOW[index] {
                return false;
            }
            index = (index + 1) % MEOW.len();
        }
        true
    }

    fn only_meow(s: &str, target: &mut OutputTarget) {
        let mut index = 0;
        let out: String = s
            .chars()
            .filter_map(|c| {
                if c == MEOW[index] {
                    index = (index + 1) % MEOW.len();
                    Some(c)
                } else {
                    None
                }
            })
            .collect();
        target.push(&out);
    }

    proptest! {
        #[test]
        fn escape_none_works(s in any::<String>()) {
            let mut output = Output::string(EscapeMode::None);
            output.push_escaped(&s);
            assert_eq!(output.into_inner(), s);
        }

        #[test]
        fn escape_html_works(s in any::<String>()) {
            let mut output = Output::string(EscapeMode::Html);
            output.push_escaped(&s);
            assert_eq!(output.into_inner(), naive_escape_html(&s));
        }

        #[test]
        fn escape_custom_works_1(s in any::<String>()) {
            let mut output = Output::string(EscapeMode::Custom(only_meow));
            output.push_escaped(&s);
            assert!(is_only_meows(&output.into_inner()));
        }

        #[test]
        fn escape_custom_works_2(s in "[meow]*") {
            let mut output = Output::string(EscapeMode::Custom(only_meow));
            output.push_escaped(&s);
            assert!(is_only_meows(&output.into_inner()));
        }
    }
}