Skip to main content

basecoat_core/
attrs.rs

1use std::borrow::Cow;
2
3/// Escape an attribute value: `&`, `<`, `>`, `"`, `'`.
4/// Only allocates when escaping is actually needed.
5pub fn escape_attr(s: &str) -> Cow<'_, str> {
6    // Fast path: scan for any char that needs escaping.
7    let needs_escape = s.chars().any(|c| matches!(c, '&' | '<' | '>' | '"' | '\''));
8    if !needs_escape {
9        return Cow::Borrowed(s);
10    }
11    let mut out = String::with_capacity(s.len() + 8);
12    for c in s.chars() {
13        match c {
14            '&' => out.push_str("&amp;"),
15            '<' => out.push_str("&lt;"),
16            '>' => out.push_str("&gt;"),
17            '"' => out.push_str("&quot;"),
18            '\'' => out.push_str("&#39;"),
19            other => out.push(other),
20        }
21    }
22    Cow::Owned(out)
23}
24
25/// An ordered list of HTML attribute key-value pairs.
26///
27/// Keys and values are stored as `Cow<'static, str>` to avoid allocations when
28/// attribute names and values are known at compile time (string literals).
29///
30/// Duplicate keys are allowed (the last one wins in most browsers, but we
31/// preserve insertion order for deterministic output and snapshot tests).
32#[derive(Clone, Debug, PartialEq, Eq, Default)]
33pub struct AttrMap(pub Vec<(Cow<'static, str>, Cow<'static, str>)>);
34
35impl AttrMap {
36    /// Create an empty `AttrMap`.
37    pub fn new() -> Self {
38        AttrMap(Vec::new())
39    }
40
41    /// Append a key-value pair.
42    pub fn push(&mut self, key: impl Into<Cow<'static, str>>, val: impl Into<Cow<'static, str>>) {
43        self.0.push((key.into(), val.into()));
44    }
45
46    /// Extend from any iterator of (key, value) pairs.
47    pub fn extend_from<I, K, V>(&mut self, iter: I)
48    where
49        I: IntoIterator<Item = (K, V)>,
50        K: Into<Cow<'static, str>>,
51        V: Into<Cow<'static, str>>,
52    {
53        for (k, v) in iter {
54            self.0.push((k.into(), v.into()));
55        }
56    }
57
58    /// Iterate over `(&str, &str)` pairs.
59    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
60        self.0.iter().map(|(k, v)| (k.as_ref(), v.as_ref()))
61    }
62
63    /// Render as an HTML attribute string.
64    ///
65    /// Returns `" key=\"value\" key2=\"value2\""` (leading space when non-empty)
66    /// or `""` when empty.  Values are HTML-attribute-escaped.
67    pub fn render(&self) -> String {
68        if self.0.is_empty() {
69            return String::new();
70        }
71        let mut out = String::new();
72        for (k, v) in &self.0 {
73            out.push(' ');
74            out.push_str(k);
75            out.push_str("=\"");
76            out.push_str(&escape_attr(v));
77            out.push('"');
78        }
79        out
80    }
81}
82
83impl<K, V> FromIterator<(K, V)> for AttrMap
84where
85    K: Into<Cow<'static, str>>,
86    V: Into<Cow<'static, str>>,
87{
88    fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
89        let mut map = AttrMap::new();
90        for (k, v) in iter {
91            map.0.push((k.into(), v.into()));
92        }
93        map
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn empty_renders_empty_string() {
103        let m = AttrMap::new();
104        assert_eq!(m.render(), "");
105    }
106
107    #[test]
108    fn single_attr_renders_with_leading_space() {
109        let mut m = AttrMap::new();
110        m.push("id", "foo");
111        assert_eq!(m.render(), r#" id="foo""#);
112    }
113
114    #[test]
115    fn escapes_dangerous_chars_in_values() {
116        let mut m = AttrMap::new();
117        m.push("data-x", r#"a"b&c<d>e'f"#);
118        assert_eq!(m.render(), r#" data-x="a&quot;b&amp;c&lt;d&gt;e&#39;f""#);
119    }
120
121    #[test]
122    fn escape_attr_no_alloc_on_safe_string() {
123        let s = "hello world";
124        let escaped = escape_attr(s);
125        assert!(matches!(escaped, Cow::Borrowed(_)));
126    }
127}