Skip to main content

tera_contrib/
regex.rs

1use std::collections::HashMap;
2use std::sync::{LazyLock, RwLock};
3
4use regex::Regex;
5use tera::{Filter, Kwargs, State, StringInput, TeraResult, Test, Value};
6
7static STRIPTAGS_RE: LazyLock<Regex> =
8    LazyLock::new(|| Regex::new(r"(<!--.*?-->|<[^>]*>)").unwrap());
9
10static SPACELESS_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r">\s+<").unwrap());
11
12/// Tries to remove HTML tags from input. Does not guarantee well-formed output if input is not valid HTML.
13///
14/// If value is "<b>Joel</b>", the output will be "Joel".
15/// Note that if the template you are using it in is automatically escaped, you will need to call the safe filter after striptags.
16///
17/// ```text
18/// {{ value | striptags }}
19/// ```
20pub fn striptags(val: &str, _: Kwargs, _: &State) -> String {
21    STRIPTAGS_RE.replace_all(val, "").into_owned()
22}
23
24/// Remove space ( ) and line breaks (\n or \r\n) between HTML tags.
25///
26/// If the value is "<p>\n<a> </a>\r\n </p>", the output will be "<p><a></a></p>".
27/// Note that only whitespace between successive opening tags and successive closing tags is removed.
28/// Also note that if the template you are using it in is automatically escaped, you will need to call the safe filter after spaceless.
29///
30/// ```text
31/// {{ value | spaceless }}
32/// ```
33pub fn spaceless(val: StringInput, _: Kwargs, _: &State) -> Value {
34    // We're removing spaces between HTML tags so if it was safe before, it should still be safe
35    // Of course if it's used outside of HTML content it might be wrong.
36    val.inherit_safety(SPACELESS_RE.replace_all(val.as_str(), "><").into_owned())
37}
38
39fn get_or_create_regex(cache: &RwLock<HashMap<String, Regex>>, pattern: &str) -> TeraResult<Regex> {
40    if let Some(r) = cache.read().unwrap().get(pattern) {
41        return Ok(r.clone());
42    }
43
44    let mut cache = cache.write().unwrap();
45
46    let regex = match Regex::new(pattern) {
47        Ok(regex) => regex,
48        Err(e) => return Err(tera::Error::message(format!("Invalid regex: {e}"))),
49    };
50
51    cache.insert(String::from(pattern), regex.clone());
52    Ok(regex)
53}
54
55/// Returns true if the given variable is a string and matches the regex in the `pat` argument.
56/// The regex will only be compiled once.
57///
58/// ```text
59/// {% if value is matching(pat="^hello") %}...{% endif %}
60/// ```
61#[derive(Debug, Default)]
62pub struct Matching {
63    cache: RwLock<HashMap<String, Regex>>,
64}
65
66impl Test<&str, TeraResult<bool>> for Matching {
67    fn call(&self, val: &str, kwargs: Kwargs, _: &State) -> TeraResult<bool> {
68        let pat = kwargs.must_get::<&str>("pat")?;
69        let regex = get_or_create_regex(&self.cache, pat)?;
70        Ok(regex.is_match(val))
71    }
72}
73
74/// Takes 2 mandatory string named arguments: `pattern` (regex pattern) and `rep`.
75/// This will replace all occurrences of `pattern` with `rep`.
76/// The regex will only be compiled once.
77///
78/// ```text
79/// {{ value | regex_replace(pattern="\d+", rep="") }}
80/// ```
81#[derive(Debug, Default)]
82pub struct RegexReplace {
83    cache: RwLock<HashMap<String, Regex>>,
84}
85
86impl Filter<&str, TeraResult<String>> for RegexReplace {
87    fn call(&self, val: &str, kwargs: Kwargs, _: &State) -> TeraResult<String> {
88        let pattern = kwargs.must_get::<&str>("pattern")?;
89        let rep = kwargs.must_get::<&str>("rep")?;
90        let regex = get_or_create_regex(&self.cache, pattern)?;
91        Ok(regex.replace_all(val, rep).into_owned())
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use std::sync::Arc;
99    use tera::value::Map;
100    use tera::{ArgFromValue, Context, StringInput};
101
102    #[test]
103    fn test_striptags() {
104        let tests = vec![
105            (
106                r"<b>Joel</b> <button>is</button> a <span>slug</span>",
107                "Joel is a slug",
108            ),
109            (
110                r#"<p>just a small   \n <a href="x"> example</a> link</p>\n<p>to a webpage</p><!-- <p>and some commented stuff</p> -->"#,
111                r#"just a small   \n  example link\nto a webpage"#,
112            ),
113            (
114                r"<p>See: &#39;&eacute; is an apostrophe followed by e acute</p>",
115                r"See: &#39;&eacute; is an apostrophe followed by e acute",
116            ),
117            (r"<adf>a", "a"),
118            (r"</adf>a", "a"),
119            (r"<asdf><asdf>e", "e"),
120            (r"hi, <f x", "hi, <f x"),
121            ("234<235, right?", "234<235, right?"),
122            ("a4<a5 right?", "a4<a5 right?"),
123            ("b7>b2!", "b7>b2!"),
124            ("</fe", "</fe"),
125            ("<x>b<y>", "b"),
126            (r#"a<p a >b</p>c"#, "abc"),
127            (r#"d<a:b c:d>e</p>f"#, "def"),
128            (
129                r#"<strong>foo</strong><a href="http://example.com">bar</a>"#,
130                "foobar",
131            ),
132            ("a &amp; b", "a &amp; b"),
133        ];
134        for (input, expected) in tests {
135            let ctx = Context::new();
136            let state = State::new(&ctx);
137            let res = striptags(input, Kwargs::default(), &state);
138            assert_eq!(expected, res);
139        }
140    }
141
142    #[test]
143    fn test_spaceless() {
144        let tests = vec![
145            ("<p>\n<a>test</a>\r\n </p>", "<p><a>test</a></p>"),
146            ("<p>\n<a> </a>\r\n </p>", "<p><a></a></p>"),
147            ("<p> </p>", "<p></p>"),
148            ("<p> <a>", "<p><a>"),
149            ("<p> test</p>", "<p> test</p>"),
150            ("<p>\r\n</p>", "<p></p>"),
151        ];
152        for (input, expected) in tests {
153            let ctx = Context::new();
154            let state = State::new(&ctx);
155            let val = Value::from(input);
156            let res = spaceless(
157                StringInput::from_value(&val).unwrap(),
158                Kwargs::default(),
159                &state,
160            );
161            assert_eq!(expected, res.as_str().unwrap());
162        }
163    }
164
165    #[test]
166    fn test_matching() {
167        let inputs = vec![
168            ("abc", "b", true),
169            ("abc", "^b$", false),
170            ("Hello, World!", r"(?i)(hello\W\sworld\W)", true),
171            ("The date was 2018-06-28", r"\d{4}-\d{2}-\d{2}$", true),
172        ];
173
174        for (input, pat, expected) in inputs {
175            let matching = Matching::default();
176            let mut map = Map::new();
177            map.insert("pat".into(), pat.into());
178            let kwargs = Kwargs::new(Arc::new(map));
179            let ctx = Context::new();
180            let res = matching.call(input, kwargs, &State::new(&ctx)).unwrap();
181            assert_eq!(expected, res);
182        }
183    }
184
185    #[test]
186    fn test_regex_replace() {
187        let regex_replace = RegexReplace::default();
188        let ctx = Context::new();
189        let state = State::new(&ctx);
190
191        // Basic replacement with capture groups
192        let mut map = Map::new();
193        map.insert(
194            "pattern".into(),
195            r"(?P<last>[^,\s]+),\s+(?P<first>\S+)".into(),
196        );
197        map.insert("rep".into(), "$first $last".into());
198        let kwargs = Kwargs::new(Arc::new(map));
199        let result = regex_replace
200            .call("Springsteen, Bruce", kwargs, &state)
201            .unwrap();
202        assert_eq!(result, "Bruce Springsteen");
203
204        // Simple replacement
205        let mut map = Map::new();
206        map.insert("pattern".into(), r"\d+".into());
207        map.insert("rep".into(), "X".into());
208        let kwargs = Kwargs::new(Arc::new(map));
209        let result = regex_replace.call("abc123def456", kwargs, &state).unwrap();
210        assert_eq!(result, "abcXdefX");
211
212        // No match returns original
213        let mut map = Map::new();
214        map.insert("pattern".into(), r"zzz".into());
215        map.insert("rep".into(), "X".into());
216        let kwargs = Kwargs::new(Arc::new(map));
217        let result = regex_replace.call("hello world", kwargs, &state).unwrap();
218        assert_eq!(result, "hello world");
219    }
220
221    #[test]
222    fn test_regex_replace_invalid_pattern() {
223        let regex_replace = RegexReplace::default();
224        let ctx = Context::new();
225        let state = State::new(&ctx);
226
227        let mut map = Map::new();
228        map.insert("pattern".into(), r"[invalid".into());
229        map.insert("rep".into(), "X".into());
230        let kwargs = Kwargs::new(Arc::new(map));
231        let result = regex_replace.call("test", kwargs, &state);
232        assert!(result.is_err());
233    }
234
235    #[test]
236    fn test_register() {
237        let mut tera = tera::Tera::default();
238        tera.register_filter("striptags", striptags);
239        tera.register_filter("spaceless", spaceless);
240        tera.register_filter("regex_replace", RegexReplace::default());
241        tera.register_test("matching", Matching::default());
242    }
243}