Skip to main content

minijinja_lua/
contrib.rs

1use mlua::prelude::{Lua, LuaError, LuaFunction, LuaValue};
2
3use crate::LuaEnvironment;
4
5/// Helper to get the lua type for minijinja wrapper userdata.
6///
7/// Returns `environment`, `state`, `none`, or any other regular lua type name.
8pub(crate) fn minijinja_types(val: &LuaValue) -> Result<&'static str, LuaError> {
9    match val {
10        LuaValue::UserData(ud) if ud.is::<LuaEnvironment>() => Ok("environment"),
11        LuaValue::UserData(ud) if ud.type_name()? == Some("state".to_string()) => Ok("state"),
12        val if val.is_null() => Ok("none"),
13        _ => Ok(val.type_name()),
14    }
15}
16
17/// Helper to load templates from a directory.
18///
19/// The returned function can be provided to `Environment:set_loader`
20pub(crate) fn minijinja_path_loader(lua: &Lua) -> Result<LuaFunction, LuaError> {
21    lua.load(
22        r#"
23        local function path_loader(paths)
24            if type(paths) == "string" then
25                paths = { paths }
26            end
27
28            local function loader(name)
29                if name:match("\\") then return nil end
30
31                name = name:gsub("^/+", ""):gsub("/+$", "")
32
33                local sep = package.config:sub(1,1)
34                local pattern = "([^" .. sep .. "]*)"
35
36                local splits = {}
37                for piece in name:gmatch(pattern) do
38                    if ".." == piece then return nil end
39                    table.insert(splits, piece)
40                end
41
42                for _, path in ipairs(paths) do
43                    local p = path .. sep .. table.concat(splits, sep)
44                    local file = io.open(p, "r")
45
46                    if file then
47                        local source = file:read("a")
48                        file:close()
49
50                        return source
51                    end
52                end
53            end
54
55            return loader
56        end
57
58        return path_loader
59    "#,
60    )
61    .eval()
62}
63
64/// Filters to work with JSON strings and objects.
65#[cfg(feature = "json")]
66pub mod json {
67    use minijinja::{Error as JinjaError, ErrorKind as JinjaErrorKind, State, Value as JinjaValue};
68
69    use crate::convert::err_to_minijinja_err;
70
71    /// Add the filters to the environment
72    pub fn add_to_environment(env: &mut minijinja::Environment) {
73        env.add_filter("fromjson", fromjson);
74    }
75
76    /// This filter allows loading minijinja objects from a JSON string.
77    ///
78    /// In lua, this allows loading a JSON object while preserving key order.
79    pub fn fromjson(_: &State, json: &[u8]) -> Result<JinjaValue, JinjaError> {
80        serde_json::from_slice(json)
81            .map_err(|err| err_to_minijinja_err(err, JinjaErrorKind::BadSerialization))
82    }
83}
84
85/// Filters to format date and time strings.
86#[cfg(feature = "datetime")]
87pub mod datetime {
88    use jiff::civil::{Date, Time};
89    use minijinja::{
90        Error as JinjaError,
91        ErrorKind as JinjaErrorKind,
92        State,
93        Value as JinjaValue,
94        value::Kwargs,
95    };
96
97    use crate::convert::err_to_minijinja_err;
98
99    /// Add the filters to the environment
100    pub fn add_to_environment(env: &mut minijinja::Environment) {
101        env.add_filter("datefmt", datefmt);
102        env.add_filter("timefmt", timefmt);
103    }
104
105    /// Formats a string into a date using the [`jiff`] crate.
106    ///
107    /// If the `format` keyword is provided, the date will be formatted according to the `strftime`
108    /// format. Otherwise, the value from [`date.to_string`](jiff::civil::Date) is returned.
109    ///
110    /// If the `patterns` keyword is provided, it must be a list of `strptime` format strings to
111    /// parse the input. Multiple patterns can be provided to allow support for various date
112    /// formats. If no patterns are provided or matched, then the default [`jiff`] formatting is
113    /// used by calling `.parse()`
114    ///
115    /// See here for available formatting patterns: <https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html>
116    pub fn datefmt(_: &State, value: JinjaValue, kwargs: Kwargs) -> Result<String, JinjaError> {
117        let format = kwargs.get::<Option<&str>>("format")?;
118        let patterns = kwargs.get::<Option<Vec<String>>>("patterns")?;
119        kwargs.assert_all_used()?;
120
121        let date = match value.as_str() {
122            Some(s) => {
123                // Try the provided patterns
124                if let Some(date) = patterns
125                    .iter()
126                    .flatten()
127                    .find_map(|f| Date::strptime(f, s).ok())
128                {
129                    Ok(date)
130                } else {
131                    // Or fallback to the `jiff` parser
132                    s.parse::<Date>()
133                        .map_err(|err| err_to_minijinja_err(err, JinjaErrorKind::CannotDeserialize))
134                }
135            },
136            None => Err(JinjaError::new(
137                JinjaErrorKind::CannotDeserialize,
138                "could not parse value as a string",
139            )),
140        }?;
141
142        Ok(match format {
143            Some(f) => date.strftime(f).to_string(),
144            None => date.to_string(),
145        })
146    }
147
148    /// Formats a string into a time using the [`jiff`] crate.
149    ///
150    /// If `format` is provided, the time will be formatted according to the `strftime` format.
151    /// Otherwise, the value from [`time.to_string()`](jiff::civil::Time) is returned.
152    ///
153    /// If `patterns` is provided, it must be a list of `strptime` format strings to parse the
154    /// input. Multiple patterns can be provided to allow support for various date formats. If no
155    /// patterns are provided or matched, then the default [`jiff`] formatting is used by calling
156    /// `.parse()`
157    ///
158    /// See here for available formatting patterns: <https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html>
159    pub fn timefmt(_: &State, value: JinjaValue, kwargs: Kwargs) -> Result<String, JinjaError> {
160        let format = kwargs.get::<Option<&str>>("format")?;
161        let patterns = kwargs.get::<Option<Vec<String>>>("patterns")?;
162        kwargs.assert_all_used()?;
163
164        let time = match value.as_str() {
165            Some(s) => {
166                // Try the provided patterns
167                if let Some(date) = patterns
168                    .iter()
169                    .flatten()
170                    .find_map(|f| Time::strptime(f, s).ok())
171                {
172                    Ok(date)
173                } else {
174                    // Or fallback to the `jiff` parser
175                    s.parse::<Time>()
176                        .map_err(|err| err_to_minijinja_err(err, JinjaErrorKind::CannotDeserialize))
177                }
178            },
179            None => Err(JinjaError::new(
180                JinjaErrorKind::CannotDeserialize,
181                "could not parse value as a string",
182            )),
183        }?;
184
185        Ok(match format {
186            Some(f) => time.strftime(f).to_string(),
187            None => time.to_string(),
188        })
189    }
190}
191
192#[cfg(test)]
193mod test {
194    use minijinja::context;
195    use mlua::Lua;
196    use serde_json::json;
197
198    use super::*;
199    use crate::state::LuaState;
200
201    fn setup() -> Lua {
202        Lua::new()
203    }
204
205    #[test]
206    fn test_minijinja_types_environment() {
207        let lua = setup();
208        let env = lua.create_userdata(LuaEnvironment::new()).unwrap();
209
210        assert_eq!(
211            minijinja_types(&LuaValue::UserData(env)).unwrap(),
212            "environment"
213        );
214    }
215
216    #[test]
217    fn test_minijinja_types_state() {
218        let lua = setup();
219        let env = minijinja::Environment::new();
220        let state = env.empty_state();
221
222        lua.scope(|scope| {
223            let ud = scope.create_userdata(LuaState::new(&state)).unwrap();
224            assert_eq!(minijinja_types(&LuaValue::UserData(ud)).unwrap(), "state");
225            Ok(())
226        })
227        .unwrap();
228    }
229
230    #[test]
231    fn test_minijinja_types_none() {
232        assert_eq!(minijinja_types(&LuaValue::NULL).unwrap(), "none");
233    }
234
235    #[test]
236    fn test_minijinja_types_lua() {
237        let lua = setup();
238
239        assert_eq!(minijinja_types(&LuaValue::Nil).unwrap(), "nil");
240        assert_eq!(
241            minijinja_types(&LuaValue::Boolean(true)).unwrap(),
242            "boolean"
243        );
244        assert_eq!(
245            minijinja_types(&LuaValue::Function(
246                lua.create_function(|_, _: LuaValue| Ok(())).unwrap()
247            ))
248            .unwrap(),
249            "function"
250        );
251        assert_eq!(minijinja_types(&LuaValue::Integer(99)).unwrap(), "integer");
252        assert_eq!(minijinja_types(&LuaValue::Number(99.99)).unwrap(), "number");
253        assert_eq!(
254            minijinja_types(&LuaValue::String(lua.create_string("foo").unwrap())).unwrap(),
255            "string"
256        );
257        assert_eq!(
258            minijinja_types(&LuaValue::Table(lua.create_table().unwrap())).unwrap(),
259            "table"
260        );
261        assert_eq!(
262            minijinja_types(&LuaValue::Thread(
263                lua.create_thread(lua.create_function(|_, _: LuaValue| Ok(())).unwrap())
264                    .unwrap()
265            ))
266            .unwrap(),
267            "thread"
268        );
269    }
270
271    #[test]
272    #[cfg(feature = "json")]
273    fn test_minijinja_from_json_filter() {
274        let mut env = minijinja::Environment::new();
275        json::add_to_environment(&mut env);
276
277        let ex = json!({"1": 1, "2": 2, "three": [1,2,3]});
278        let expr = env.compile_expression("te | fromjson").unwrap();
279
280        let res = expr.eval(context! { te => ex.to_string() }).unwrap();
281
282        assert_eq!(res, minijinja::Value::from_serialize(ex));
283    }
284
285    #[test]
286    #[cfg(feature = "datetime")]
287    fn test_minijinja_datefmt_filter() {
288        let mut env = minijinja::Environment::new();
289        datetime::add_to_environment(&mut env);
290
291        let date = "2000-01-01";
292        let ex = "2000-01-01";
293
294        let expr = env.compile_expression("te | datefmt").unwrap();
295        let res = expr.eval(context! { te => date }).unwrap();
296
297        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", date, ex);
298    }
299
300    #[test]
301    #[cfg(feature = "datetime")]
302    fn test_minijinja_datefmt_filter_format() {
303        let mut env = minijinja::Environment::new();
304        datetime::add_to_environment(&mut env);
305
306        let date: &str = "2000-01-01T11:12:13";
307        let ex = "January 1, 2000";
308        let fmt = "%B %-d, %Y";
309
310        let te = format!("te | datefmt(format='{}')", fmt);
311        let expr = env.compile_expression(&te).unwrap();
312
313        let res = expr.eval(context! { te => date }).unwrap();
314
315        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", date, ex);
316    }
317
318    #[test]
319    #[cfg(feature = "datetime")]
320    fn test_minijinja_datefmt_filter_parse() {
321        let mut env = minijinja::Environment::new();
322        datetime::add_to_environment(&mut env);
323
324        let date = "2026 1 January";
325        let ex = "2026-01-01";
326        let patt = "%Y %-d %B";
327
328        let te = format!("te | datefmt(patterns=['{}'])", patt);
329        let expr = env.compile_expression(&te).unwrap();
330
331        let res = expr.eval(context! { te => date }).unwrap();
332
333        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", date, ex);
334    }
335
336    #[test]
337    #[cfg(feature = "datetime")]
338    fn test_minijinja_timefmt_filter() {
339        let mut env = minijinja::Environment::new();
340        datetime::add_to_environment(&mut env);
341
342        let time = "2000-01-01T11:12:13";
343        let ex = "11:12:13";
344
345        let expr = env.compile_expression("te | timefmt").unwrap();
346        let res = expr.eval(context! { te => time }).unwrap();
347
348        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", time, ex);
349    }
350
351    #[test]
352    #[cfg(feature = "datetime")]
353    fn test_minijinja_timefmt_filter_format() {
354        let mut env = minijinja::Environment::new();
355        datetime::add_to_environment(&mut env);
356
357        let time = "12:02:31";
358        let ex = "31:02:12";
359        let fmt = "%S:%M:%H";
360
361        let te = format!("te | timefmt(format='{}')", fmt);
362        let expr = env.compile_expression(&te).unwrap();
363
364        let res = expr.eval(context! { te => time }).unwrap();
365
366        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", time, ex);
367    }
368
369    #[test]
370    #[cfg(feature = "datetime")]
371    fn test_minijinja_timefmt_filter_parse() {
372        let mut env = minijinja::Environment::new();
373        datetime::add_to_environment(&mut env);
374
375        let time = "04 02 09";
376        let ex = "02:04:09";
377        let patt = "%M %H %S";
378
379        let te = format!("te | timefmt(patterns=['{}'])", patt);
380        let expr = env.compile_expression(&te).unwrap();
381
382        let res = expr.eval(context! { te => time }).unwrap();
383
384        assert_eq!(res.as_str().unwrap(), ex, "{} should parse to {}", time, ex);
385    }
386}