lithos_gotmpl_core/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2pub use lithos_gotmpl_engine::{
3    analyze_template, coerce_number, is_empty, is_truthy, value_to_string, AnalysisIssue,
4    Certainty, ControlKind, ControlUsage, Error, EvalContext, FunctionCall, FunctionRegistry,
5    FunctionRegistryBuilder, FunctionSource, Precision, Template, TemplateAnalysis, TemplateCall,
6    VariableAccess, VariableKind,
7};
8use serde_json::Number;
9use serde_json::Value;
10
11/// Returns a registry populated with the standard Go text/template helper functions.
12pub fn text_template_functions() -> FunctionRegistry {
13    let mut builder = FunctionRegistryBuilder::new();
14    install_text_template_functions(&mut builder);
15    builder.build()
16}
17
18/// Installs the standard Go text/template helper functions into an existing registry builder.
19pub fn install_text_template_functions(builder: &mut FunctionRegistryBuilder) {
20    builder
21        .register("and", builtin_and)
22        .register("call", builtin_call)
23        .register("html", builtin_html)
24        .register("eq", builtin_eq)
25        .register("ge", builtin_ge)
26        .register("gt", builtin_gt)
27        .register("index", builtin_index)
28        .register("js", builtin_js)
29        .register("len", builtin_len)
30        .register("le", builtin_le)
31        .register("lt", builtin_lt)
32        .register("ne", builtin_ne)
33        .register("not", builtin_not)
34        .register("print", builtin_print)
35        .register("println", builtin_println)
36        .register("or", builtin_or)
37        .register("printf", builtin_printf)
38        .register("slice", builtin_slice)
39        .register("urlquery", builtin_urlquery);
40}
41
42fn builtin_eq(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
43    if args.len() < 2 {
44        return Err(Error::render("eq expects two arguments", None));
45    }
46    Ok(Value::Bool(args[0] == args[1]))
47}
48
49fn builtin_ne(ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
50    builtin_eq(ctx, args).map(|v| Value::Bool(!v.as_bool().unwrap()))
51}
52
53fn builtin_lt(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
54    comparison(args, |a, b| a < b, |a, b| a < b)
55}
56
57fn builtin_le(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
58    comparison(args, |a, b| a <= b, |a, b| a <= b)
59}
60
61fn builtin_gt(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
62    comparison(args, |a, b| a > b, |a, b| a > b)
63}
64
65fn builtin_ge(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
66    comparison(args, |a, b| a >= b, |a, b| a >= b)
67}
68
69fn comparison<F, G>(args: &[Value], num: F, str_op: G) -> Result<Value, Error>
70where
71    F: Fn(f64, f64) -> bool,
72    G: Fn(&str, &str) -> bool,
73{
74    if args.len() < 2 {
75        return Err(Error::render("comparison expects two arguments", None));
76    }
77    let lhs = &args[0];
78    let rhs = &args[1];
79    if lhs.is_number() && rhs.is_number() {
80        compare_numbers(lhs, rhs, num)
81    } else if lhs.is_string() && rhs.is_string() {
82        Ok(Value::Bool(str_op(
83            lhs.as_str().unwrap(),
84            rhs.as_str().unwrap(),
85        )))
86    } else {
87        Err(Error::render(
88            "comparison requires both arguments to be numbers or strings",
89            None,
90        ))
91    }
92}
93
94fn compare_numbers<F>(lhs: &Value, rhs: &Value, cmp: F) -> Result<Value, Error>
95where
96    F: Fn(f64, f64) -> bool,
97{
98    let left = coerce_number(lhs)?;
99    let right = coerce_number(rhs)?;
100    Ok(Value::Bool(cmp(left, right)))
101}
102
103fn builtin_printf(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
104    if args.is_empty() {
105        return Err(Error::render("printf expects format string", None));
106    }
107    let format = args[0]
108        .as_str()
109        .ok_or_else(|| Error::render("printf expects format string as first argument", None))?;
110
111    let mut output = String::new();
112    let mut chars = format.chars().peekable();
113    let mut arg_index = 1usize;
114
115    while let Some(ch) = chars.next() {
116        if ch != '%' {
117            output.push(ch);
118            continue;
119        }
120
121        let Some(next) = chars.next() else {
122            return Err(Error::render("incomplete format specifier", None));
123        };
124
125        if next == '%' {
126            output.push('%');
127            continue;
128        }
129
130        if arg_index >= args.len() {
131            return Err(Error::render("not enough arguments for printf", None));
132        }
133        let arg = &args[arg_index];
134        arg_index += 1;
135
136        let formatted = match next {
137            's' | 'v' => value_to_string(arg),
138            'd' | 'b' | 'o' | 'x' | 'X' => format_integer(arg)?,
139            'f' | 'g' | 'e' | 'E' => format_float(arg)?,
140            _ => {
141                let mut s = String::from("%");
142                s.push(next);
143                s.push_str(&value_to_string(arg));
144                s
145            }
146        };
147        output.push_str(&formatted);
148    }
149
150    if arg_index < args.len() {
151        let mut first_extra = true;
152        for extra in &args[arg_index..] {
153            if !first_extra {
154                output.push(' ');
155            }
156            first_extra = false;
157            output.push_str(&value_to_string(extra));
158        }
159    }
160
161    Ok(Value::String(output))
162}
163
164fn builtin_print(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
165    let mut output = String::new();
166    for value in args {
167        output.push_str(&value_to_string(value));
168    }
169    Ok(Value::String(output))
170}
171
172fn builtin_println(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
173    let mut output = String::new();
174    let mut first = true;
175    for value in args {
176        if !first {
177            output.push(' ');
178        }
179        first = false;
180        output.push_str(&value_to_string(value));
181    }
182    output.push('\n');
183    Ok(Value::String(output))
184}
185
186fn builtin_html(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
187    if args.len() != 1 {
188        return Err(Error::render("html expects exactly one argument", None));
189    }
190    let input = value_to_string(&args[0]);
191    Ok(Value::String(escape_html(&input)))
192}
193
194fn builtin_js(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
195    if args.len() != 1 {
196        return Err(Error::render("js expects exactly one argument", None));
197    }
198    let input = value_to_string(&args[0]);
199    Ok(Value::String(escape_js(&input)))
200}
201
202fn builtin_urlquery(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
203    if args.len() != 1 {
204        return Err(Error::render("urlquery expects exactly one argument", None));
205    }
206    let input = value_to_string(&args[0]);
207    Ok(Value::String(escape_urlquery(&input)))
208}
209
210fn escape_html(input: &str) -> String {
211    let mut output = String::with_capacity(input.len());
212    for ch in input.chars() {
213        match ch {
214            '&' => output.push_str("&amp;"),
215            '<' => output.push_str("&lt;"),
216            '>' => output.push_str("&gt;"),
217            '"' => output.push_str("&#34;"),
218            '\'' => output.push_str("&#39;"),
219            _ => output.push(ch),
220        }
221    }
222    output
223}
224
225fn escape_js(input: &str) -> String {
226    let mut json = serde_json::to_string(input).unwrap_or_else(|_| String::from("\"\""));
227    // strip surrounding quotes
228    if json.len() >= 2 {
229        json = json[1..json.len() - 1].to_string();
230    }
231    let mut result = String::with_capacity(json.len());
232    for ch in json.chars() {
233        match ch {
234            '<' => result.push_str("\\u003C"),
235            '>' => result.push_str("\\u003E"),
236            '&' => result.push_str("\\u0026"),
237            '=' => result.push_str("\\u003D"),
238            '\'' => result.push_str("\\u0027"),
239            '"' => result.push_str("\\u0022"),
240            '\u{2028}' => result.push_str("\\u2028"),
241            '\u{2029}' => result.push_str("\\u2029"),
242            _ => result.push(ch),
243        }
244    }
245    result
246}
247
248fn escape_urlquery(input: &str) -> String {
249    let mut output = String::with_capacity(input.len());
250    for b in input.bytes() {
251        match b {
252            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
253                output.push(b as char)
254            }
255            b' ' => output.push('+'),
256            _ => {
257                output.push('%');
258                output.push_str(&format!("{:02X}", b));
259            }
260        }
261    }
262    output
263}
264
265fn builtin_index(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
266    if args.is_empty() {
267        return Err(Error::render("index expects at least one argument", None));
268    }
269
270    let mut current = args[0].clone();
271    for key in &args[1..] {
272        current = match (&current, key) {
273            (Value::Object(map), Value::String(s)) => map.get(s).cloned().unwrap_or(Value::Null),
274            (Value::Object(map), Value::Number(num)) => {
275                let key = num.to_string();
276                map.get(&key).cloned().unwrap_or(Value::Null)
277            }
278            (Value::Array(list), Value::Number(num)) => {
279                let idx = num
280                    .as_u64()
281                    .ok_or_else(|| Error::render("array index must be unsigned integer", None))?
282                    as usize;
283                list.get(idx).cloned().unwrap_or(Value::Null)
284            }
285            (Value::Array(list), Value::String(s)) => {
286                let idx = s
287                    .parse::<usize>()
288                    .map_err(|_| Error::render("array index must be integer", None))?;
289                list.get(idx).cloned().unwrap_or(Value::Null)
290            }
291            _ => return Err(Error::render("index expects map or array container", None)),
292        };
293    }
294
295    Ok(current)
296}
297
298fn builtin_and(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
299    for value in args {
300        if !is_truthy(value) {
301            return Ok(value.clone());
302        }
303    }
304    Ok(args.last().cloned().unwrap_or(Value::Bool(true)))
305}
306
307fn builtin_or(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
308    for value in args {
309        if is_truthy(value) {
310            return Ok(value.clone());
311        }
312    }
313    Ok(args.last().cloned().unwrap_or(Value::Bool(false)))
314}
315
316fn builtin_len(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
317    if args.len() != 1 {
318        return Err(Error::render("len expects exactly one argument", None));
319    }
320    let len = match &args[0] {
321        Value::Null => 0,
322        Value::String(s) => s.len(),
323        Value::Array(list) => list.len(),
324        Value::Object(map) => map.len(),
325        Value::Bool(_) | Value::Number(_) => {
326            return Err(Error::render("len expects array, map, or string", None));
327        }
328    };
329    Ok(Value::Number(Number::from(len as u64)))
330}
331
332fn builtin_slice(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
333    if args.is_empty() {
334        return Err(Error::render("slice expects at least one argument", None));
335    }
336    let target = &args[0];
337    let indices: Result<Vec<usize>, Error> = args[1..]
338        .iter()
339        .map(|arg| {
340            if let Some(idx) = arg.as_u64().or_else(|| {
341                arg.as_i64()
342                    .and_then(|v| if v >= 0 { Some(v as u64) } else { None })
343            }) {
344                Ok(idx as usize)
345            } else if let Some(text) = arg.as_str() {
346                text.parse::<usize>()
347                    .map_err(|_| Error::render("slice indices must be non-negative integers", None))
348            } else {
349                Err(Error::render(
350                    "slice indices must be non-negative integers",
351                    None,
352                ))
353            }
354        })
355        .collect();
356    let indices = indices?;
357    if indices.len() > 2 {
358        return Err(Error::render("slice supports at most two indices", None));
359    }
360
361    match target {
362        Value::String(s) => {
363            let len = s.len();
364            let (start, end) = slice_bounds(&indices, len)?;
365            let slice = s
366                .get(start..end)
367                .ok_or_else(|| Error::render("slice indices not on char boundaries", None))?;
368            Ok(Value::String(slice.to_string()))
369        }
370        Value::Array(list) => {
371            let len = list.len();
372            let (start, end) = slice_bounds(&indices, len)?;
373            Ok(Value::Array(list[start..end].to_vec()))
374        }
375        Value::Null => Ok(Value::Array(Vec::new())),
376        _ => Err(Error::render(
377            "slice expects string or array as first argument",
378            None,
379        )),
380    }
381}
382
383fn slice_bounds(indices: &[usize], len: usize) -> Result<(usize, usize), Error> {
384    let start = indices.first().copied().unwrap_or(0);
385    let end = indices.get(1).copied().unwrap_or(len);
386    if start > end || end > len {
387        return Err(Error::render("slice indices out of range", None));
388    }
389    Ok((start, end))
390}
391
392fn builtin_call(ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
393    if args.is_empty() {
394        return Err(Error::render("call expects at least one argument", None));
395    }
396    let func_name = args[0]
397        .as_str()
398        .ok_or_else(|| Error::render("call expects function name as string", None))?;
399    let func = ctx
400        .function(func_name)
401        .ok_or_else(|| Error::render(format!("unknown function \"{func_name}\""), None))?;
402    func(ctx, &args[1..])
403}
404
405fn builtin_not(_ctx: &mut EvalContext, args: &[Value]) -> Result<Value, Error> {
406    if args.len() != 1 {
407        return Err(Error::render("not expects exactly one argument", None));
408    }
409    Ok(Value::Bool(!is_truthy(&args[0])))
410}
411
412fn format_integer(value: &Value) -> Result<String, Error> {
413    if let Some(i) = value.as_i64() {
414        return Ok(i.to_string());
415    }
416    if let Some(u) = value.as_u64() {
417        return Ok(u.to_string());
418    }
419    if let Some(s) = value.as_str() {
420        if let Ok(parsed) = s.parse::<i128>() {
421            return Ok(parsed.to_string());
422        }
423    }
424    let coerced = coerce_number(value)?;
425    if coerced.fract() == 0.0 {
426        Ok(format!("{:.0}", coerced))
427    } else {
428        Ok(coerced.to_string())
429    }
430}
431
432fn format_float(value: &Value) -> Result<String, Error> {
433    if let Some(f) = value.as_f64() {
434        return Ok(trim_trailing_zeros(f));
435    }
436    if let Some(i) = value.as_i64() {
437        return Ok(trim_trailing_zeros(i as f64));
438    }
439    if let Some(u) = value.as_u64() {
440        return Ok(trim_trailing_zeros(u as f64));
441    }
442    if let Some(s) = value.as_str() {
443        if let Ok(parsed) = s.parse::<f64>() {
444            return Ok(trim_trailing_zeros(parsed));
445        }
446    }
447    Ok(trim_trailing_zeros(coerce_number(value)?))
448}
449
450fn trim_trailing_zeros(value: f64) -> String {
451    let mut s = format!("{}", value);
452    if s.contains('.') {
453        while s.ends_with('0') {
454            s.pop();
455        }
456        if s.ends_with('.') {
457            s.pop();
458        }
459    }
460    s
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use serde_json::json;
467
468    #[test]
469    fn html_escapes_like_go_docs() {
470        let functions = text_template_functions();
471        let tmpl =
472            Template::parse_with_functions("html", r#"{{html "<b>\"Bob\"</b>"}}"#, functions)
473                .unwrap();
474        let result = tmpl.render(&json!({})).unwrap();
475        assert_eq!(result, "&lt;b&gt;&#34;Bob&#34;&lt;/b&gt;");
476    }
477
478    #[test]
479    fn js_escapes_quotes_and_tags() {
480        let functions = text_template_functions();
481        let tmpl =
482            Template::parse_with_functions("js", r#"{{js "</script>"}}"#, functions).unwrap();
483        let result = tmpl.render(&json!({})).unwrap();
484        assert_eq!(result, "\\u003C/script\\u003E");
485    }
486
487    #[test]
488    fn urlquery_encodes_spaces_as_plus() {
489        let functions = text_template_functions();
490        let tmpl = Template::parse_with_functions(
491            "urlquery",
492            r#"{{urlquery "Hello, world!"}}"#,
493            functions,
494        )
495        .unwrap();
496        let result = tmpl.render(&json!({})).unwrap();
497        assert_eq!(result, "Hello%2C+world%21");
498    }
499
500    #[test]
501    fn print_concatenates_arguments() {
502        let functions = text_template_functions();
503        let tmpl =
504            Template::parse_with_functions("print", r#"{{print "Hello" 23}}"#, functions).unwrap();
505        let result = tmpl.render(&json!({})).unwrap();
506        assert_eq!(result, "Hello23");
507    }
508
509    #[test]
510    fn println_adds_spaces_and_newline() {
511        let functions = text_template_functions();
512        let tmpl =
513            Template::parse_with_functions("println", r#"{{println "Hello" 23}}"#, functions)
514                .unwrap();
515        let result = tmpl.render(&json!({})).unwrap();
516        assert_eq!(result, "Hello 23\n");
517    }
518
519    #[test]
520    fn len_counts_elements() {
521        let functions = text_template_functions();
522        let tmpl = Template::parse_with_functions("len", r#"{{len .items}}"#, functions).unwrap();
523        let result = tmpl.render(&json!({ "items": [1, 2, 3] })).unwrap();
524        assert_eq!(result, "3");
525    }
526
527    #[test]
528    fn slice_subsets_array() {
529        let functions = text_template_functions();
530        let tmpl = Template::parse_with_functions("slice", r#"{{slice .word "1" "3"}}"#, functions)
531            .unwrap();
532        let result = tmpl.render(&json!({ "word": "rustacean" })).unwrap();
533        assert_eq!(result, "us");
534    }
535
536    #[test]
537    fn call_invokes_registered_function() {
538        let mut builder = FunctionRegistryBuilder::new();
539        install_text_template_functions(&mut builder);
540        builder.register("greet", |_ctx, args| {
541            let name = args.first().and_then(|v| v.as_str()).unwrap_or("friend");
542            Ok(Value::String(format!("Hello, {name}!")))
543        });
544        let registry = builder.build();
545        let tmpl =
546            Template::parse_with_functions("call", r#"{{call "greet" "Rust"}}"#, registry).unwrap();
547        let result = tmpl.render(&json!({})).unwrap();
548        assert_eq!(result, "Hello, Rust!");
549    }
550}