1pub 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
11pub fn text_template_functions() -> FunctionRegistry {
13 let mut builder = FunctionRegistryBuilder::new();
14 install_text_template_functions(&mut builder);
15 builder.build()
16}
17
18pub 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("&"),
215 '<' => output.push_str("<"),
216 '>' => output.push_str(">"),
217 '"' => output.push_str("""),
218 '\'' => output.push_str("'"),
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 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 (¤t, 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, "<b>"Bob"</b>");
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}