Skip to main content

minijinja_lua/
contrib.rs

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