1pub mod ast;
2pub mod error;
3pub mod lexer;
4pub mod parser;
5pub mod renderer;
6pub mod value;
7
8pub use error::{Error, Result};
9pub use renderer::{Engine, FilterFn, LoaderFn};
10pub use value::Value;
11
12#[macro_export]
38macro_rules! context {
39 ($($key:ident => $value:expr),* $(,)?) => {{
40 let mut _map = ::std::collections::HashMap::<::std::string::String, $crate::Value>::new();
41 $(_map.insert(::std::stringify!($key).to_owned(), $crate::Value::from($value));)*
42 _map
43 }};
44}
45
46#[cfg(test)]
47mod tests {
48 use super::*;
49 use std::collections::HashMap;
50
51 fn ctx(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
52 pairs
53 .iter()
54 .map(|(k, v)| (k.to_string(), v.clone()))
55 .collect()
56 }
57
58 fn render(src: &str, pairs: &[(&str, Value)]) -> String {
59 Engine::new()
60 .render(src, ctx(pairs))
61 .expect("render failed")
62 }
63
64 #[test]
67 fn basic_interpolation() {
68 assert_eq!(
69 render("Hello, {= name}!", &[("name", Value::from("World"))]),
70 "Hello, World!"
71 );
72 }
73
74 #[test]
75 fn auto_escape() {
76 let out = render("{= s}", &[("s", Value::from("<b>hi</b>"))]);
77 assert_eq!(out, "<b>hi</b>");
78 }
79
80 #[test]
81 fn raw_html_tag() {
82 let out = render("{~ s}", &[("s", Value::from("<b>hi</b>"))]);
83 assert_eq!(out, "<b>hi</b>");
84 }
85
86 #[test]
87 fn member_access() {
88 let mut obj = HashMap::new();
89 obj.insert("name".to_string(), Value::from("Alice"));
90 let out = render("{= user.name}", &[("user", Value::Object(obj))]);
91 assert_eq!(out, "Alice");
92 }
93
94 #[test]
95 fn comment_stripped() {
96 assert_eq!(render("a{! comment !}b", &[]), "ab");
97 }
98
99 #[test]
102 fn if_true() {
103 let out = render("{#if flag}yes{/if}", &[("flag", Value::Bool(true))]);
104 assert_eq!(out, "yes");
105 }
106
107 #[test]
108 fn if_false() {
109 let out = render("{#if flag}yes{/if}", &[("flag", Value::Bool(false))]);
110 assert_eq!(out, "");
111 }
112
113 #[test]
114 fn if_else() {
115 let out = render(
116 "{#if flag}yes{:else}no{/if}",
117 &[("flag", Value::Bool(false))],
118 );
119 assert_eq!(out, "no");
120 }
121
122 #[test]
123 fn if_else_if() {
124 let out = render(
125 "{#if x == 1}one{:else if x == 2}two{:else}other{/if}",
126 &[("x", Value::Int(2))],
127 );
128 assert_eq!(out, "two");
129 }
130
131 #[test]
134 fn each_basic() {
135 let items = Value::Array(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
136 let out = render("{#each items as item}{= item}{/each}", &[("items", items)]);
137 assert_eq!(out, "abc");
138 }
139
140 #[test]
141 fn each_with_index() {
142 let items = Value::Array(vec![Value::from("x"), Value::from("y")]);
143 let out = render(
144 "{#each items as item, i}{= i}:{= item} {/each}",
145 &[("items", items)],
146 );
147 assert_eq!(out, "0:x 1:y ");
148 }
149
150 #[test]
151 fn each_with_loop_metadata() {
152 let items = Value::Array(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
153 let out = render(
154 "{#each items as item, i, loop}{= item}({= loop.index}/{= loop.length},first={= loop.first},last={= loop.last}) {/each}",
155 &[("items", items)],
156 );
157 assert_eq!(
158 out,
159 "a(0/3,first=true,last=false) b(1/3,first=false,last=false) c(2/3,first=false,last=true) "
160 );
161 }
162
163 #[test]
164 fn each_else_empty() {
165 let out = render(
166 "{#each items as item}{= item}{:else}empty{/each}",
167 &[("items", Value::Array(vec![]))],
168 );
169 assert_eq!(out, "empty");
170 }
171
172 #[test]
173 fn each_destructure() {
174 let mut obj = HashMap::new();
175 obj.insert("name".to_string(), Value::from("Alice"));
176 obj.insert("age".to_string(), Value::Int(30));
177 let items = Value::Array(vec![Value::Object(obj)]);
178 let out = render(
179 "{#each items as { name, age }}{= name}={= age}{/each}",
180 &[("items", items)],
181 );
182 assert_eq!(out, "Alice=30");
183 }
184
185 #[test]
188 fn snippet_and_render() {
189 let out = render(
190 "{#snippet greet(who)}Hello, {= who}!{/snippet}{@render greet(\"World\")}",
191 &[],
192 );
193 assert_eq!(out, "Hello, World!");
194 }
195
196 #[test]
199 fn raw_block() {
200 let out = render("{#raw}{name} is {#if} not parsed{/raw}", &[]);
201 assert_eq!(out, "{name} is {#if} not parsed");
202 }
203
204 #[test]
207 fn escape_expr_sigil() {
208 let out = render(r"{\= name}", &[("name", Value::from("Alice"))]);
210 assert_eq!(out, "{= name}");
211 }
212
213 #[test]
214 fn escape_raw_sigil() {
215 let out = render(r"{\~ name}", &[("name", Value::from("Alice"))]);
217 assert_eq!(out, "{~ name}");
218 }
219
220 #[test]
223 fn const_tag() {
224 let out = render("{@const x = 42}{= x}", &[]);
225 assert_eq!(out, "42");
226 }
227
228 #[test]
231 fn ternary() {
232 let out = render("{= x > 0 ? \"pos\" : \"non-pos\"}", &[("x", Value::Int(5))]);
233 assert_eq!(out, "pos");
234 }
235
236 #[test]
237 fn nullish_coalesce() {
238 let out = render("{= name ?? \"default\"}", &[("name", Value::Null)]);
239 assert_eq!(out, "default");
240 }
241
242 #[test]
243 fn string_concat() {
244 let out = render(
245 "{= a + \" \" + b}",
246 &[("a", Value::from("Hello")), ("b", Value::from("World"))],
247 );
248 assert_eq!(out, "Hello World");
249 }
250
251 #[test]
252 fn arithmetic() {
253 let out = render(
254 "{= a + b * 2}",
255 &[("a", Value::Int(1)), ("b", Value::Int(3))],
256 );
257 assert_eq!(out, "7");
258 }
259
260 #[test]
261 fn unary_not() {
262 let out = render("{#if !flag}yes{/if}", &[("flag", Value::Bool(false))]);
263 assert_eq!(out, "yes");
264 }
265
266 #[test]
269 fn filter_upper() {
270 let out = render("{= s | upper}", &[("s", Value::from("hello"))]);
271 assert_eq!(out, "HELLO");
272 }
273
274 #[test]
275 fn filter_lower() {
276 let out = render("{= s | lower}", &[("s", Value::from("HELLO"))]);
277 assert_eq!(out, "hello");
278 }
279
280 #[test]
281 fn filter_truncate() {
282 let out = render(
283 "{= s | truncate(5)}",
284 &[("s", Value::from("Hello, World!"))],
285 );
286 assert_eq!(out, "He...");
287 }
288
289 #[test]
290 fn filter_join() {
291 let items = Value::Array(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
292 let out = render("{= items | join(\", \")}", &[("items", items)]);
293 assert_eq!(out, "a, b, c");
294 }
295
296 #[test]
297 fn filter_length() {
298 let out = render("{= s | length}", &[("s", Value::from("hello"))]);
299 assert_eq!(out, "5");
300 }
301
302 #[test]
303 fn filter_default() {
304 let out = render("{= name | default(\"anon\")}", &[("name", Value::Null)]);
305 assert_eq!(out, "anon");
306 }
307
308 #[test]
309 fn filter_round() {
310 let out = render("{= n | round(2)}", &[("n", Value::Float(1.23456))]);
311 assert_eq!(out, "1.23");
312 }
313
314 #[test]
315 fn filter_chain() {
316 let out = render(
317 "{= s | lower | capitalize}",
318 &[("s", Value::from("HELLO WORLD"))],
319 );
320 assert_eq!(out, "Hello world");
321 }
322
323 #[test]
326 fn test_defined() {
327 let out = render(
328 "{#if x is defined}yes{:else}no{/if}",
329 &[("x", Value::Int(1))],
330 );
331 assert_eq!(out, "yes");
332 let out = render("{#if x is defined}yes{:else}no{/if}", &[]);
333 assert_eq!(out, "no");
334 }
335
336 #[test]
337 fn test_empty() {
338 let out = render("{#if s is empty}yes{/if}", &[("s", Value::from(""))]);
339 assert_eq!(out, "yes");
340 }
341
342 #[test]
345 fn in_array() {
346 let items = Value::Array(vec![Value::from("a"), Value::from("b")]);
347 let out = render(
348 "{#if x in items}yes{:else}no{/if}",
349 &[("x", Value::from("a")), ("items", items)],
350 );
351 assert_eq!(out, "yes");
352 }
353
354 #[test]
357 fn show_error_messages() {
358 use std::collections::HashMap as M;
359 let cases: &[(&str, &str)] = &[
360 ("unclosed string", r#"Hello {= "world}"#),
361 ("unclosed if block", r#"{#if true}hello"#),
362 ("bad special tag", r#"{@foo bar}"#),
363 ("unknown filter", r#"{= name | shout}"#),
364 ];
365 for (label, tmpl) in cases {
366 let err = Engine::new().render(tmpl, M::new()).unwrap_err();
367 println!("[{}]\n template: {:?}\n error: {}\n", label, tmpl, err);
368 }
369 let err = Engine::new()
370 .with_strict()
371 .render("{= missing}", M::new())
372 .unwrap_err();
373 println!("[strict undefined]\n error: {}\n", err);
374 }
375
376 #[test]
384 fn standalone_block_strips_its_line() {
385 let out = render("before\n{#if true}\nyes\n{/if}\nafter", &[]);
388 assert_eq!(out, "before\nyes\nafter");
389 }
390
391 #[test]
392 fn standalone_with_indentation() {
393 let out = render("before\n {#if true}\n yes\n {/if}\nafter", &[]);
395 assert_eq!(out, "before\n yes\nafter");
396 }
397
398 #[test]
399 fn inline_block_not_standalone() {
400 let out = render("a {#if true}b{/if} c", &[]);
402 assert_eq!(out, "a b c");
403 }
404
405 #[test]
406 fn standalone_each_strips_its_line() {
407 let items = Value::Array(vec![Value::from("x"), Value::from("y")]);
408 let out = render(
409 "list:\n{#each items as item}\n- {= item}\n{/each}\ndone",
410 &[("items", items)],
411 );
412 assert_eq!(out, "list:\n- x\n- y\ndone");
413 }
414
415 #[test]
418 fn value_is_truthy() {
419 assert!(!Value::Null.is_truthy());
420 assert!(!Value::Bool(false).is_truthy());
421 assert!(Value::Bool(true).is_truthy());
422 assert!(!Value::Int(0).is_truthy());
423 assert!(Value::Int(1).is_truthy());
424 assert!(Value::Int(-1).is_truthy());
425 assert!(!Value::Float(0.0).is_truthy());
426 assert!(Value::Float(1.5).is_truthy());
427 assert!(!Value::String(String::new()).is_truthy());
428 assert!(Value::String("x".into()).is_truthy());
429 assert!(Value::Array(vec![]).is_truthy()); assert!(Value::Array(vec![Value::Int(1)]).is_truthy());
431 assert!(Value::Object(HashMap::new()).is_truthy());
432 }
433
434 #[test]
435 fn value_is_null() {
436 assert!(Value::Null.is_null());
437 assert!(!Value::Bool(false).is_null());
438 assert!(!Value::Int(0).is_null());
439 assert!(!Value::String(String::new()).is_null());
440 }
441
442 #[test]
443 fn value_type_names() {
444 assert_eq!(Value::Null.type_name(), "null");
445 assert_eq!(Value::Bool(true).type_name(), "bool");
446 assert_eq!(Value::Int(1).type_name(), "int");
447 assert_eq!(Value::Float(1.0).type_name(), "float");
448 assert_eq!(Value::String("x".into()).type_name(), "string");
449 assert_eq!(Value::Array(vec![]).type_name(), "array");
450 assert_eq!(Value::Object(HashMap::new()).type_name(), "object");
451 }
452
453 #[test]
454 fn value_display_string() {
455 assert_eq!(Value::Null.to_display_string(), "null");
456 assert_eq!(Value::Bool(true).to_display_string(), "true");
457 assert_eq!(Value::Bool(false).to_display_string(), "false");
458 assert_eq!(Value::Int(42).to_display_string(), "42");
459 assert_eq!(Value::Float(1.5).to_display_string(), "1.5");
460 assert_eq!(Value::Float(2.0).to_display_string(), "2");
462 assert_eq!(Value::String("hi".into()).to_display_string(), "hi");
463 assert_eq!(
464 Value::Array(vec![Value::Int(1), Value::Int(2)]).to_display_string(),
465 "1,2"
466 );
467 assert_eq!(
468 Value::Object(HashMap::new()).to_display_string(),
469 "[object Object]"
470 );
471 }
472
473 #[test]
474 fn value_json_string() {
475 assert_eq!(Value::Null.to_json_string(), "null");
476 assert_eq!(Value::Bool(true).to_json_string(), "true");
477 assert_eq!(Value::Bool(false).to_json_string(), "false");
478 assert_eq!(Value::Int(7).to_json_string(), "7");
479 assert_eq!(Value::Float(1.5).to_json_string(), "1.5");
480 assert_eq!(Value::String("hello".into()).to_json_string(), r#""hello""#);
481 assert_eq!(
483 Value::String("a\"b\\c\nd\re\t".into()).to_json_string(),
484 r#""a\"b\\c\nd\re\t""#
485 );
486 assert_eq!(
488 Value::Array(vec![Value::Int(1), Value::Bool(true), Value::Null]).to_json_string(),
489 "[1,true,null]"
490 );
491 let mut obj = HashMap::new();
493 obj.insert("z".to_string(), Value::Int(1));
494 obj.insert("a".to_string(), Value::Int(2));
495 assert_eq!(Value::Object(obj).to_json_string(), r#"{"a":2,"z":1}"#);
496 }
497
498 #[test]
499 fn value_length_and_is_empty() {
500 assert_eq!(Value::String("hello".into()).length(), Some(5));
501 assert_eq!(
502 Value::Array(vec![Value::Int(1), Value::Int(2)]).length(),
503 Some(2)
504 );
505 assert_eq!(Value::Object(HashMap::new()).length(), Some(0));
506 assert_eq!(Value::Null.length(), None);
507 assert_eq!(Value::Int(1).length(), None);
508
509 assert!(Value::String(String::new()).is_empty());
510 assert!(!Value::String("x".into()).is_empty());
511 assert!(Value::Array(vec![]).is_empty());
512 assert!(!Value::Array(vec![Value::Null]).is_empty());
513 assert!(Value::Object(HashMap::new()).is_empty());
514 assert!(!Value::Null.is_empty());
516 assert!(!Value::Int(0).is_empty());
517 assert!(!Value::Bool(false).is_empty());
518 }
519
520 #[test]
521 fn value_html_escape_fn() {
522 use crate::value::html_escape;
523 assert_eq!(
524 html_escape(r#"<div class="x">&it's</div>"#),
525 "<div class="x">&it's</div>"
526 );
527 assert_eq!(html_escape("no specials"), "no specials");
528 }
529
530 #[test]
531 fn value_urlencode_fn() {
532 use crate::value::urlencode;
533 assert_eq!(urlencode("hello world"), "hello%20world");
534 assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e"); assert_eq!(urlencode("a+b=c&d"), "a%2Bb%3Dc%26d");
536 assert_eq!(urlencode(""), "");
537 }
538
539 #[test]
540 fn value_from_impls() {
541 assert_eq!(Value::from(true), Value::Bool(true));
542 assert_eq!(Value::from(false), Value::Bool(false));
543 assert_eq!(Value::from(42i64), Value::Int(42));
544 assert_eq!(Value::from(1.5f64), Value::Float(1.5));
545 assert_eq!(Value::from("hi"), Value::String("hi".into()));
546 assert_eq!(Value::from("hi".to_string()), Value::String("hi".into()));
547 assert_eq!(
548 Value::from(vec![Value::Null]),
549 Value::Array(vec![Value::Null])
550 );
551 let mut m = HashMap::new();
552 m.insert("k".to_string(), Value::Int(1));
553 assert!(matches!(Value::from(m), Value::Object(_)));
554 }
555
556 #[test]
557 fn value_display_trait() {
558 assert_eq!(format!("{}", Value::Int(99)), "99");
559 assert_eq!(format!("{}", Value::from("hi")), "hi");
560 }
561
562 #[test]
565 fn error_display_variants() {
566 use crate::error::{Error, Span};
567 let lex = Error::LexError {
568 message: "bad token".into(),
569 span: Span::new(3, 7, 0),
570 };
571 assert!(lex.to_string().contains("Lex error at 3:7: bad token"));
572 let parse = Error::ParseError {
573 message: "bad syntax".into(),
574 span: Span::unknown(),
575 };
576 assert!(parse.to_string().contains("Parse error at 0:0: bad syntax"));
577 let render = Error::RenderError {
578 message: "bad render".into(),
579 };
580 assert!(render.to_string().contains("Render error: bad render"));
581 }
582
583 #[test]
586 fn add_two_strings() {
587 let out = render(
588 "{= a + ' ' + b}",
589 &[("a", Value::from("Hello")), ("b", Value::from("World"))],
590 );
591 assert_eq!(out, "Hello World");
592 }
593
594 #[test]
595 fn add_int_and_string_coerces_to_string() {
596 let out = render("{= count + ' items'}", &[("count", Value::Int(5))]);
597 assert_eq!(out, "5 items");
598 }
599
600 #[test]
601 fn add_null_and_string_coerces() {
602 let out = render("{= x + ' end'}", &[("x", Value::Null)]);
603 assert_eq!(out, "null end");
604 }
605
606 #[test]
609 fn strict_null_property_access_errors() {
610 let err = Engine::new()
611 .with_strict()
612 .render("{= x.y}", ctx(&[("x", Value::Null)]))
613 .unwrap_err();
614 assert!(err.to_string().contains("null"));
615 }
616
617 #[test]
618 fn strict_missing_property_errors() {
619 let mut obj = HashMap::new();
620 obj.insert("a".to_string(), Value::Int(1));
621 let err = Engine::new()
622 .with_strict()
623 .render("{= x.b}", ctx(&[("x", Value::Object(obj))]))
624 .unwrap_err();
625 assert!(err.to_string().contains("not found"));
626 }
627
628 #[test]
629 fn strict_array_out_of_bounds_errors() {
630 let arr = Value::Array(vec![Value::Int(1)]);
631 let err = Engine::new()
632 .with_strict()
633 .render("{= a[5]}", ctx(&[("a", arr)]))
634 .unwrap_err();
635 assert!(err.to_string().contains("out of bounds"));
636 }
637
638 #[test]
639 fn lax_out_of_bounds_returns_null() {
640 let arr = Value::Array(vec![Value::Int(1)]);
641 let out = render("{= a[5] ?? 'none'}", &[("a", arr)]);
642 assert_eq!(out, "none");
643 }
644
645 #[test]
646 fn lax_null_member_returns_null() {
647 let out = render("{= x.y ?? 'nil'}", &[("x", Value::Null)]);
648 assert_eq!(out, "nil");
649 }
650
651 #[test]
652 fn member_access_non_object_strict_errors() {
653 let err = Engine::new()
654 .with_strict()
655 .render("{= a.b}", ctx(&[("a", Value::Int(1))]))
656 .unwrap_err();
657 assert!(err.to_string().contains("Cannot access property"));
658 }
659
660 #[test]
661 fn member_access_non_object_lax_is_null() {
662 let out = render("{= a.b ?? 'nil'}", &[("a", Value::Int(1))]);
663 assert_eq!(out, "nil");
664 }
665
666 #[test]
669 fn division_by_zero_int() {
670 let err = Engine::new()
671 .render("{= a / 0}", ctx(&[("a", Value::Int(5))]))
672 .unwrap_err();
673 assert!(err.to_string().contains("Division by zero"));
674 }
675
676 #[test]
677 fn division_by_zero_float() {
678 let err = Engine::new()
679 .render("{= a / 0.0}", ctx(&[("a", Value::Float(1.0))]))
680 .unwrap_err();
681 assert!(err.to_string().contains("Division by zero"));
682 }
683
684 #[test]
685 fn subtraction_non_numbers_errors() {
686 let err = Engine::new()
687 .render(
688 "{= a - b}",
689 ctx(&[("a", Value::from("x")), ("b", Value::from("y"))]),
690 )
691 .unwrap_err();
692 assert!(err.to_string().contains("Arithmetic requires numbers"));
693 }
694
695 #[test]
696 fn unary_neg_non_number_errors() {
697 let err = Engine::new()
698 .render("{= -a}", ctx(&[("a", Value::from("x"))]))
699 .unwrap_err();
700 assert!(err.to_string().contains("negate"));
701 }
702
703 #[test]
706 fn compare_incompatible_types_errors() {
707 let err = Engine::new()
708 .render(
709 "{= a < b}",
710 ctx(&[("a", Value::Int(1)), ("b", Value::from("x"))]),
711 )
712 .unwrap_err();
713 assert!(err.to_string().contains("Cannot compare"));
714 }
715
716 #[test]
717 fn compare_strings_lexicographically() {
718 assert_eq!(
719 render(
720 "{= a < b ? 'yes' : 'no'}",
721 &[("a", Value::from("abc")), ("b", Value::from("xyz"))]
722 ),
723 "yes"
724 );
725 }
726
727 #[test]
728 fn compare_int_and_float() {
729 assert_eq!(
730 render(
731 "{= a >= b ? 'yes' : 'no'}",
732 &[("a", Value::Int(2)), ("b", Value::Float(1.5))]
733 ),
734 "yes"
735 );
736 }
737
738 #[test]
741 fn test_undefined() {
742 assert_eq!(render("{#if x is undefined}yes{:else}no{/if}", &[]), "yes");
743 assert_eq!(
744 render(
745 "{#if x is undefined}yes{:else}no{/if}",
746 &[("x", Value::Int(1))]
747 ),
748 "no"
749 );
750 }
751
752 #[test]
753 fn test_none() {
754 assert_eq!(
755 render("{#if x is none}yes{/if}", &[("x", Value::Null)]),
756 "yes"
757 );
758 assert_eq!(
759 render("{#if x is none}no{:else}yes{/if}", &[("x", Value::Int(1))]),
760 "yes"
761 );
762 }
763
764 #[test]
765 fn test_truthy_falsy() {
766 assert_eq!(
767 render("{#if x is truthy}yes{/if}", &[("x", Value::Int(1))]),
768 "yes"
769 );
770 assert_eq!(
771 render(
772 "{#if x is truthy}no{:else}yes{/if}",
773 &[("x", Value::Int(0))]
774 ),
775 "yes"
776 );
777 assert_eq!(
778 render("{#if x is falsy}yes{/if}", &[("x", Value::Int(0))]),
779 "yes"
780 );
781 assert_eq!(
782 render(
783 "{#if x is falsy}no{:else}yes{/if}",
784 &[("x", Value::Bool(true))]
785 ),
786 "yes"
787 );
788 }
789
790 #[test]
791 fn test_string_number_iterable() {
792 assert_eq!(
793 render("{#if x is string}yes{/if}", &[("x", Value::from("hi"))]),
794 "yes"
795 );
796 assert_eq!(
797 render(
798 "{#if x is string}no{:else}yes{/if}",
799 &[("x", Value::Int(1))]
800 ),
801 "yes"
802 );
803 assert_eq!(
804 render("{#if x is number}yes{/if}", &[("x", Value::Int(1))]),
805 "yes"
806 );
807 assert_eq!(
808 render("{#if x is number}yes{/if}", &[("x", Value::Float(1.0))]),
809 "yes"
810 );
811 assert_eq!(
812 render(
813 "{#if x is iterable}yes{/if}",
814 &[("x", Value::Array(vec![]))]
815 ),
816 "yes"
817 );
818 assert_eq!(
819 render(
820 "{#if x is iterable}no{:else}yes{/if}",
821 &[("x", Value::Int(1))]
822 ),
823 "yes"
824 );
825 }
826
827 #[test]
828 fn test_odd_even_non_number_errors() {
829 let err = Engine::new()
830 .render("{#if x is odd}y{/if}", ctx(&[("x", Value::from("a"))]))
831 .unwrap_err();
832 assert!(err.to_string().contains("odd"));
833 let err = Engine::new()
834 .render("{#if x is even}y{/if}", ctx(&[("x", Value::from("a"))]))
835 .unwrap_err();
836 assert!(err.to_string().contains("even"));
837 }
838
839 #[test]
840 fn test_unknown_in_strict_mode_errors() {
841 let err = Engine::new()
842 .with_strict()
843 .render("{#if x is foobar}y{/if}", ctx(&[("x", Value::Int(1))]))
844 .unwrap_err();
845 assert!(err.to_string().contains("Unknown test"));
846 }
847
848 #[test]
849 fn test_is_not() {
850 assert_eq!(
851 render("{#if x is not empty}yes{/if}", &[("x", Value::from("hi"))]),
852 "yes"
853 );
854 assert_eq!(
855 render(
856 "{#if x is not empty}no{:else}yes{/if}",
857 &[("x", Value::from(""))]
858 ),
859 "yes"
860 );
861 assert_eq!(render("{#if x is not defined}yes{/if}", &[]), "yes");
863 assert_eq!(
865 render(
866 "{#if x is not defined}yes{:else}no{/if}",
867 &[("x", Value::Null)]
868 ),
869 "yes"
870 );
871 }
872
873 #[test]
876 fn in_string_substring() {
877 let out = render(
878 "{#if x in s}yes{:else}no{/if}",
879 &[("x", Value::from("ell")), ("s", Value::from("hello"))],
880 );
881 assert_eq!(out, "yes");
882 }
883
884 #[test]
885 fn in_object_checks_keys() {
886 let mut obj = HashMap::new();
887 obj.insert("name".to_string(), Value::Int(1));
888 assert_eq!(
889 render("{#if 'name' in o}yes{/if}", &[("o", Value::Object(obj))]),
890 "yes"
891 );
892 }
893
894 #[test]
895 fn not_in_array() {
896 let arr = Value::Array(vec![Value::from("a"), Value::from("b")]);
897 assert_eq!(
898 render("{#if 'c' not in items}yes{/if}", &[("items", arr)]),
899 "yes"
900 );
901 }
902
903 #[test]
904 fn in_incompatible_type_errors() {
905 let err = Engine::new()
906 .render("{#if 1 in x}y{/if}", ctx(&[("x", Value::Int(42))]))
907 .unwrap_err();
908 assert!(err.to_string().contains("'in' operator"));
909 }
910
911 #[test]
914 fn index_negative_wraps() {
915 let arr = Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
916 assert_eq!(render("{= a[-1]}", &[("a", arr)]), "3");
917 }
918
919 #[test]
920 fn index_object_by_string_key() {
921 let mut obj = HashMap::new();
922 obj.insert("key".to_string(), Value::from("val"));
923 assert_eq!(render("{= o['key']}", &[("o", Value::Object(obj))]), "val");
924 }
925
926 #[test]
927 fn index_non_integer_on_array_errors() {
928 let arr = Value::Array(vec![Value::Int(1)]);
929 let err = Engine::new()
930 .render("{= a['x']}", ctx(&[("a", arr)]))
931 .unwrap_err();
932 assert!(err.to_string().contains("integer"));
933 }
934
935 #[test]
936 fn index_into_null_strict_errors() {
937 let err = Engine::new()
938 .with_strict()
939 .render("{= a[0]}", ctx(&[("a", Value::Null)]))
940 .unwrap_err();
941 assert!(err.to_string().contains("null"));
942 }
943
944 #[test]
945 fn index_into_non_collection_errors() {
946 let err = Engine::new()
947 .render("{= a[0]}", ctx(&[("a", Value::Int(5))]))
948 .unwrap_err();
949 assert!(err.to_string().contains("Cannot index"));
950 }
951
952 #[test]
955 fn each_non_array_errors() {
956 let err = Engine::new()
957 .render(
958 "{#each x as item}{= item}{/each}",
959 ctx(&[("x", Value::Int(1))]),
960 )
961 .unwrap_err();
962 assert!(err.to_string().contains("array"));
963 }
964
965 #[test]
966 fn each_null_iterable_uses_else() {
967 let out = render(
968 "{#each x as item}{= item}{:else}empty{/each}",
969 &[("x", Value::Null)],
970 );
971 assert_eq!(out, "empty");
972 }
973
974 #[test]
977 fn render_unknown_snippet_errors() {
978 let err = Engine::new()
979 .render("{@render missing()}", ctx(&[]))
980 .unwrap_err();
981 assert!(err.to_string().contains("Unknown snippet"));
982 }
983
984 #[test]
985 fn render_wrong_arg_count_errors() {
986 let err = Engine::new()
987 .render("{#snippet foo(a, b)}ok{/snippet}{@render foo(1)}", ctx(&[]))
988 .unwrap_err();
989 assert!(err.to_string().contains("expects"));
990 }
991
992 #[test]
995 fn include_not_found_errors() {
996 let err = Engine::new()
997 .render("{@include 'no_such.html'}", ctx(&[]))
998 .unwrap_err();
999 assert!(
1000 err.to_string().to_lowercase().contains("template")
1001 || err.to_string().contains("not found")
1002 );
1003 }
1004
1005 #[test]
1006 fn include_error_names_template() {
1007 let err = Engine::new()
1008 .register_template("child.gtl", "{#if not valid}oops{/if}")
1009 .render("{@include 'child.gtl'}", ctx(&[]))
1010 .unwrap_err();
1011 let msg = err.to_string();
1012 assert!(
1013 msg.contains("child.gtl"),
1014 "error should name the failing template: {msg}"
1015 );
1016 }
1017
1018 #[test]
1019 fn include_error_chain() {
1020 let err = Engine::new()
1021 .register_template("child.gtl", "{#if not valid}oops{/if}")
1022 .register_template("parent.gtl", "{@include 'child.gtl'}")
1023 .render("{@include 'parent.gtl'}", ctx(&[]))
1024 .unwrap_err();
1025 let msg = err.to_string();
1026 assert!(
1027 msg.contains("child.gtl"),
1028 "error should name the failing template: {msg}"
1029 );
1030 assert!(
1031 msg.contains("parent.gtl"),
1032 "error should show the include chain: {msg}"
1033 );
1034 }
1035
1036 #[test]
1039 fn filter_replace() {
1040 let out = render("{= s | replace('o', '0')}", &[("s", Value::from("foobar"))]);
1041 assert_eq!(out, "f00bar");
1042 }
1043
1044 #[test]
1045 fn filter_split_and_join() {
1046 let out = render(
1047 "{= s | split(',') | join(' ')}",
1048 &[("s", Value::from("a,b,c"))],
1049 );
1050 assert_eq!(out, "a b c");
1051 }
1052
1053 #[test]
1054 fn filter_sort_and_reverse() {
1055 let arr = Value::Array(vec![Value::from("c"), Value::from("a"), Value::from("b")]);
1056 assert_eq!(
1057 render("{= items | sort | join(',')}", &[("items", arr.clone())]),
1058 "a,b,c"
1059 );
1060 assert_eq!(
1061 render("{= items | reverse | join(',')}", &[("items", arr)]),
1062 "b,a,c"
1063 );
1064 }
1065
1066 #[test]
1067 fn filter_reverse_string() {
1068 assert_eq!(
1069 render("{= s | reverse}", &[("s", Value::from("abc"))]),
1070 "cba"
1071 );
1072 }
1073
1074 #[test]
1075 fn filter_first_and_last() {
1076 let arr = Value::Array(vec![Value::Int(10), Value::Int(20), Value::Int(30)]);
1077 assert_eq!(render("{= items | first}", &[("items", arr.clone())]), "10");
1078 assert_eq!(render("{= items | last}", &[("items", arr)]), "30");
1079 }
1080
1081 #[test]
1082 fn filter_first_last_empty_array() {
1083 let arr = Value::Array(vec![]);
1085 assert_eq!(
1086 render("{= items | first}", &[("items", arr.clone())]),
1087 "null"
1088 );
1089 assert_eq!(render("{= items | last}", &[("items", arr)]), "null");
1090 }
1091
1092 #[test]
1093 fn filter_json() {
1094 let arr = Value::Array(vec![Value::Int(1), Value::Bool(false), Value::Null]);
1095 assert_eq!(render("{= v | json}", &[("v", arr)]), "[1,false,null]");
1096 }
1097
1098 #[test]
1099 fn filter_json_object() {
1100 let mut obj = HashMap::new();
1101 obj.insert("x".to_string(), Value::Int(1));
1102 let out = Engine::new()
1104 .render("{~ v | json}", ctx(&[("v", Value::Object(obj))]))
1105 .unwrap();
1106 assert_eq!(out, r#"{"x":1}"#);
1107 }
1108
1109 #[test]
1110 fn filter_urlencode() {
1111 let out = render("{= s | urlencode}", &[("s", Value::from("a b+c"))]);
1112 assert_eq!(out, "a%20b%2Bc");
1113 }
1114
1115 #[test]
1116 fn filter_escape_inside_html_tag() {
1117 let out = render("{~ s | escape}", &[("s", Value::from("<b>bold</b>"))]);
1118 assert_eq!(out, "<b>bold</b>");
1119 }
1120
1121 #[test]
1122 fn filter_unknown_errors() {
1123 let err = Engine::new()
1124 .render("{= s | nosuchfilter}", ctx(&[("s", Value::from("x"))]))
1125 .unwrap_err();
1126 assert!(err.to_string().contains("Unknown filter"));
1127 }
1128
1129 #[test]
1130 fn filter_wrong_type_string_filters() {
1131 for tmpl in [
1132 "{= n | upper}",
1133 "{= n | lower}",
1134 "{= n | capitalize}",
1135 "{= n | trim}",
1136 "{= n | truncate(5)}",
1137 "{= n | split(',')}",
1138 "{= n | urlencode}",
1139 ] {
1140 let err = Engine::new()
1141 .render(tmpl, ctx(&[("n", Value::Int(1))]))
1142 .unwrap_err();
1143 assert!(
1144 matches!(err, crate::Error::RenderError { .. }),
1145 "expected RenderError for {tmpl}"
1146 );
1147 }
1148 }
1149
1150 #[test]
1151 fn filter_wrong_type_collection_filters() {
1152 for tmpl in [
1153 "{= n | sort}",
1154 "{= n | join}",
1155 "{= n | first}",
1156 "{= n | last}",
1157 ] {
1158 let err = Engine::new()
1159 .render(tmpl, ctx(&[("n", Value::from("x"))]))
1160 .unwrap_err();
1161 assert!(
1162 matches!(err, crate::Error::RenderError { .. }),
1163 "expected RenderError for {tmpl}"
1164 );
1165 }
1166 }
1167
1168 #[test]
1169 fn filter_reverse_wrong_type_errors() {
1170 let err = Engine::new()
1171 .render("{= n | reverse}", ctx(&[("n", Value::Int(1))]))
1172 .unwrap_err();
1173 assert!(matches!(err, crate::Error::RenderError { .. }));
1174 }
1175
1176 #[test]
1177 fn filter_round_wrong_type_errors() {
1178 let err = Engine::new()
1179 .render("{= n | round}", ctx(&[("n", Value::from("x"))]))
1180 .unwrap_err();
1181 assert!(matches!(err, crate::Error::RenderError { .. }));
1182 }
1183
1184 #[test]
1185 fn filter_length_wrong_type_errors() {
1186 let err = Engine::new()
1187 .render("{= n | length}", ctx(&[("n", Value::Int(1))]))
1188 .unwrap_err();
1189 assert!(matches!(err, crate::Error::RenderError { .. }));
1190 }
1191
1192 #[test]
1195 fn render_name_via_loader() {
1196 let engine = Engine::new().with_template_loader(|name| match name {
1197 "greet" => Ok("Hello, {= who}!".to_string()),
1198 other => Err(crate::Error::RenderError {
1199 message: format!("not found: {other}"),
1200 }),
1201 });
1202 let mut c = HashMap::new();
1203 c.insert("who".to_string(), Value::from("World"));
1204 assert_eq!(engine.render_name("greet", c).unwrap(), "Hello, World!");
1205 }
1206
1207 #[test]
1208 fn render_name_loader_missing_errors() {
1209 let engine = Engine::new().with_template_loader(|name| {
1210 Err(crate::Error::RenderError {
1211 message: format!("not found: {name}"),
1212 })
1213 });
1214 let err = engine.render_name("missing", HashMap::new()).unwrap_err();
1215 assert!(err.to_string().contains("not found"));
1216 }
1217
1218 #[test]
1219 fn compile_and_render_template() {
1220 let engine = Engine::new();
1221 let tpl = engine.compile("Hello, {= name}!").unwrap();
1222 let mut c = HashMap::new();
1223 c.insert("name".to_string(), Value::from("World"));
1224 let out = engine.render_template(&tpl, c).unwrap();
1225 assert_eq!(out, "Hello, World!");
1226 }
1227
1228 #[test]
1231 fn lex_unterminated_string_errors() {
1232 let err = Engine::new()
1233 .render("{= \"unclosed}", ctx(&[]))
1234 .unwrap_err();
1235 assert!(matches!(err, crate::Error::LexError { .. }));
1236 }
1237
1238 #[test]
1239 fn lex_unknown_escape_errors() {
1240 let err = Engine::new().render("{= \"\\z\"}", ctx(&[])).unwrap_err();
1241 assert!(err.to_string().contains("escape"));
1242 }
1243
1244 #[test]
1245 fn lex_unicode_escape() {
1246 assert_eq!(render("{= \"\\u{41}\"}", &[]), "A");
1248 assert_eq!(render("{= \"\\u{1F600}\"}", &[]), "😀");
1250 }
1251
1252 #[test]
1253 fn lex_lone_ampersand_errors() {
1254 let err = Engine::new()
1255 .render(
1256 "{= a & b}",
1257 ctx(&[("a", Value::Int(1)), ("b", Value::Int(2))]),
1258 )
1259 .unwrap_err();
1260 assert!(err.to_string().contains("&&"));
1261 }
1262
1263 #[test]
1264 fn lex_unclosed_comment_errors() {
1265 let err = Engine::new().render("{! unclosed", ctx(&[])).unwrap_err();
1266 assert!(matches!(err, crate::Error::LexError { .. }));
1267 }
1268
1269 #[test]
1270 fn lex_unclosed_raw_block_errors() {
1271 let err = Engine::new()
1272 .render("{#raw}unclosed", ctx(&[]))
1273 .unwrap_err();
1274 assert!(matches!(err, crate::Error::LexError { .. }));
1275 }
1276
1277 #[test]
1278 fn lex_float_scientific_notation() {
1279 assert_eq!(render("{= 1.5e1}", &[]), "15");
1280 assert_eq!(render("{= 1.0e2}", &[]), "100");
1281 }
1282
1283 #[test]
1286 fn parse_unclosed_if_errors() {
1287 let err = Engine::new()
1288 .render("{#if true}unclosed", ctx(&[]))
1289 .unwrap_err();
1290 assert!(matches!(err, crate::Error::ParseError { .. }));
1291 }
1292
1293 #[test]
1294 fn parse_unknown_special_tag_errors() {
1295 let err = Engine::new().render("{@unknown}", ctx(&[])).unwrap_err();
1296 assert!(matches!(err, crate::Error::ParseError { .. }));
1297 }
1298
1299 #[test]
1300 fn parse_array_literal() {
1301 let out = render("{= [1, 2, 3] | join(',')}", &[]);
1302 assert_eq!(out, "1,2,3");
1303 }
1304
1305 #[test]
1306 fn parse_nested_array_literal() {
1307 let out = render("{= ['a', 'b', 'c'] | length}", &[]);
1308 assert_eq!(out, "3");
1309 }
1310
1311 #[test]
1314 fn context_macro_flat() {
1315 let ctx = context! { name => "Alice", score => 42_i64, active => true };
1316 let out = Engine::new()
1317 .render("{= name} {= score} {= active}", ctx)
1318 .unwrap();
1319 assert_eq!(out, "Alice 42 true");
1320 }
1321
1322 #[test]
1323 fn context_macro_nested() {
1324 let ctx = context! {
1325 user => Value::from(context! { name => "Bob", age => 25_i64 }),
1326 };
1327 let out = Engine::new()
1328 .render("{= user.name} is {= user.age}", ctx)
1329 .unwrap();
1330 assert_eq!(out, "Bob is 25");
1331 }
1332
1333 #[cfg(feature = "serde")]
1336 mod serde_tests {
1337 use super::*;
1338 use serde::Serialize;
1339
1340 #[test]
1341 fn render_from_struct() {
1342 #[derive(Serialize)]
1343 struct Ctx {
1344 name: String,
1345 count: u32,
1346 }
1347 let out = Engine::new()
1348 .render_from(
1349 "{= name} ({= count})",
1350 &Ctx {
1351 name: "Widget".into(),
1352 count: 3,
1353 },
1354 )
1355 .unwrap();
1356 assert_eq!(out, "Widget (3)");
1357 }
1358
1359 #[test]
1360 fn render_from_json_literal() {
1361 let out = Engine::new()
1362 .render_from(
1363 "{#each items as item}{= item} {/each}",
1364 &serde_json::json!({ "items": ["a", "b", "c"] }),
1365 )
1366 .unwrap();
1367 assert_eq!(out, "a b c ");
1368 }
1369
1370 #[test]
1371 fn value_from_serialize_scalar() {
1372 assert_eq!(Value::from_serialize(&42_i32), Value::Int(42));
1373 assert_eq!(Value::from_serialize(&true), Value::Bool(true));
1374 assert_eq!(Value::from_serialize(&"hello"), Value::from("hello"));
1375 }
1376
1377 #[test]
1378 fn value_from_serialize_nested_struct() {
1379 #[derive(Serialize)]
1380 struct Inner {
1381 x: i32,
1382 }
1383 #[derive(Serialize)]
1384 struct Outer {
1385 inner: Inner,
1386 label: String,
1387 }
1388 let v = Value::from_serialize(&Outer {
1389 inner: Inner { x: 7 },
1390 label: "test".into(),
1391 });
1392 let out = Engine::new()
1393 .render(
1394 "{= inner.x} {= label}",
1395 HashMap::from([
1396 (
1397 "inner".to_string(),
1398 match &v {
1399 Value::Object(m) => m["inner"].clone(),
1400 _ => unreachable!(),
1401 },
1402 ),
1403 (
1404 "label".to_string(),
1405 match &v {
1406 Value::Object(m) => m["label"].clone(),
1407 _ => unreachable!(),
1408 },
1409 ),
1410 ]),
1411 )
1412 .unwrap();
1413 assert_eq!(out, "7 test");
1414 }
1415
1416 #[test]
1417 fn render_from_rejects_non_object() {
1418 let err = Engine::new().render_from("{= x}", &42_i32).unwrap_err();
1419 assert!(err.to_string().contains("object"));
1420 }
1421
1422 #[test]
1423 fn serde_roundtrip_value() {
1424 let original = Value::Object(HashMap::from([
1425 ("a".to_string(), Value::Int(1)),
1426 ("b".to_string(), Value::Bool(true)),
1427 ("c".to_string(), Value::from("hello")),
1428 ]));
1429 let json = serde_json::to_string(&original).unwrap();
1430 let restored: Value = serde_json::from_str(&json).unwrap();
1431 assert_eq!(original, restored);
1432 }
1433 }
1434}