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#[cfg(test)]
13mod tests {
14 use super::*;
15 use std::collections::HashMap;
16
17 fn ctx(pairs: &[(&str, Value)]) -> HashMap<String, Value> {
18 pairs
19 .iter()
20 .map(|(k, v)| (k.to_string(), v.clone()))
21 .collect()
22 }
23
24 fn render(src: &str, pairs: &[(&str, Value)]) -> String {
25 Engine::new()
26 .render(src, ctx(pairs))
27 .expect("render failed")
28 }
29
30 #[test]
33 fn basic_interpolation() {
34 assert_eq!(
35 render("Hello, {name}!", &[("name", Value::from("World"))]),
36 "Hello, World!"
37 );
38 }
39
40 #[test]
41 fn auto_escape() {
42 let out = render("{s}", &[("s", Value::from("<b>hi</b>"))]);
43 assert_eq!(out, "<b>hi</b>");
44 }
45
46 #[test]
47 fn raw_html_tag() {
48 let out = render("{@html s}", &[("s", Value::from("<b>hi</b>"))]);
49 assert_eq!(out, "<b>hi</b>");
50 }
51
52 #[test]
53 fn member_access() {
54 let mut obj = HashMap::new();
55 obj.insert("name".to_string(), Value::from("Alice"));
56 let out = render("{user.name}", &[("user", Value::Object(obj))]);
57 assert_eq!(out, "Alice");
58 }
59
60 #[test]
61 fn comment_stripped() {
62 assert_eq!(render("a{! comment !}b", &[]), "ab");
63 }
64
65 #[test]
68 fn if_true() {
69 let out = render("{#if flag}yes{/if}", &[("flag", Value::Bool(true))]);
70 assert_eq!(out, "yes");
71 }
72
73 #[test]
74 fn if_false() {
75 let out = render("{#if flag}yes{/if}", &[("flag", Value::Bool(false))]);
76 assert_eq!(out, "");
77 }
78
79 #[test]
80 fn if_else() {
81 let out = render(
82 "{#if flag}yes{:else}no{/if}",
83 &[("flag", Value::Bool(false))],
84 );
85 assert_eq!(out, "no");
86 }
87
88 #[test]
89 fn if_else_if() {
90 let out = render(
91 "{#if x == 1}one{:else if x == 2}two{:else}other{/if}",
92 &[("x", Value::Int(2))],
93 );
94 assert_eq!(out, "two");
95 }
96
97 #[test]
100 fn each_basic() {
101 let items = Value::Array(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
102 let out = render("{#each items as item}{item}{/each}", &[("items", items)]);
103 assert_eq!(out, "abc");
104 }
105
106 #[test]
107 fn each_with_index() {
108 let items = Value::Array(vec![Value::from("x"), Value::from("y")]);
109 let out = render(
110 "{#each items as item, i}{i}:{item} {/each}",
111 &[("items", items)],
112 );
113 assert_eq!(out, "0:x 1:y ");
114 }
115
116 #[test]
117 fn each_else_empty() {
118 let out = render(
119 "{#each items as item}{item}{:else}empty{/each}",
120 &[("items", Value::Array(vec![]))],
121 );
122 assert_eq!(out, "empty");
123 }
124
125 #[test]
126 fn each_destructure() {
127 let mut obj = HashMap::new();
128 obj.insert("name".to_string(), Value::from("Alice"));
129 obj.insert("age".to_string(), Value::Int(30));
130 let items = Value::Array(vec![Value::Object(obj)]);
131 let out = render(
132 "{#each items as { name, age }}{name}={age}{/each}",
133 &[("items", items)],
134 );
135 assert_eq!(out, "Alice=30");
136 }
137
138 #[test]
141 fn snippet_and_render() {
142 let out = render(
143 "{#snippet greet(who)}Hello, {who}!{/snippet}{@render greet(\"World\")}",
144 &[],
145 );
146 assert_eq!(out, "Hello, World!");
147 }
148
149 #[test]
152 fn raw_block() {
153 let out = render("{#raw}{name} is {#if} not parsed{/raw}", &[]);
154 assert_eq!(out, "{name} is {#if} not parsed");
155 }
156
157 #[test]
160 fn const_tag() {
161 let out = render("{@const x = 42}{x}", &[]);
162 assert_eq!(out, "42");
163 }
164
165 #[test]
168 fn ternary() {
169 let out = render("{x > 0 ? \"pos\" : \"non-pos\"}", &[("x", Value::Int(5))]);
170 assert_eq!(out, "pos");
171 }
172
173 #[test]
174 fn nullish_coalesce() {
175 let out = render("{name ?? \"default\"}", &[("name", Value::Null)]);
176 assert_eq!(out, "default");
177 }
178
179 #[test]
180 fn string_concat() {
181 let out = render(
182 "{a + \" \" + b}",
183 &[("a", Value::from("Hello")), ("b", Value::from("World"))],
184 );
185 assert_eq!(out, "Hello World");
186 }
187
188 #[test]
189 fn arithmetic() {
190 let out = render("{a + b * 2}", &[("a", Value::Int(1)), ("b", Value::Int(3))]);
191 assert_eq!(out, "7");
192 }
193
194 #[test]
195 fn unary_not() {
196 let out = render("{#if !flag}yes{/if}", &[("flag", Value::Bool(false))]);
197 assert_eq!(out, "yes");
198 }
199
200 #[test]
203 fn filter_upper() {
204 let out = render("{s | upper}", &[("s", Value::from("hello"))]);
205 assert_eq!(out, "HELLO");
206 }
207
208 #[test]
209 fn filter_lower() {
210 let out = render("{s | lower}", &[("s", Value::from("HELLO"))]);
211 assert_eq!(out, "hello");
212 }
213
214 #[test]
215 fn filter_truncate() {
216 let out = render("{s | truncate(5)}", &[("s", Value::from("Hello, World!"))]);
217 assert_eq!(out, "He...");
218 }
219
220 #[test]
221 fn filter_join() {
222 let items = Value::Array(vec![Value::from("a"), Value::from("b"), Value::from("c")]);
223 let out = render("{items | join(\", \")}", &[("items", items)]);
224 assert_eq!(out, "a, b, c");
225 }
226
227 #[test]
228 fn filter_length() {
229 let out = render("{s | length}", &[("s", Value::from("hello"))]);
230 assert_eq!(out, "5");
231 }
232
233 #[test]
234 fn filter_default() {
235 let out = render("{name | default(\"anon\")}", &[("name", Value::Null)]);
236 assert_eq!(out, "anon");
237 }
238
239 #[test]
240 fn filter_round() {
241 let out = render("{n | round(2)}", &[("n", Value::Float(1.23456))]);
242 assert_eq!(out, "1.23");
243 }
244
245 #[test]
246 fn filter_chain() {
247 let out = render(
248 "{s | lower | capitalize}",
249 &[("s", Value::from("HELLO WORLD"))],
250 );
251 assert_eq!(out, "Hello world");
252 }
253
254 #[test]
257 fn test_defined() {
258 let out = render(
259 "{#if x is defined}yes{:else}no{/if}",
260 &[("x", Value::Int(1))],
261 );
262 assert_eq!(out, "yes");
263 let out = render("{#if x is defined}yes{:else}no{/if}", &[]);
264 assert_eq!(out, "no");
265 }
266
267 #[test]
268 fn test_empty() {
269 let out = render("{#if s is empty}yes{/if}", &[("s", Value::from(""))]);
270 assert_eq!(out, "yes");
271 }
272
273 #[test]
276 fn in_array() {
277 let items = Value::Array(vec![Value::from("a"), Value::from("b")]);
278 let out = render(
279 "{#if x in items}yes{:else}no{/if}",
280 &[("x", Value::from("a")), ("items", items)],
281 );
282 assert_eq!(out, "yes");
283 }
284
285 #[test]
288 fn show_error_messages() {
289 use std::collections::HashMap as M;
290 let cases: &[(&str, &str)] = &[
291 ("unclosed string", r#"Hello {"world}"#),
292 ("unclosed if block", r#"{#if true}hello"#),
293 ("bad special tag", r#"{@foo bar}"#),
294 ("unknown filter", r#"{name | shout}"#),
295 ];
296 for (label, tmpl) in cases {
297 let err = Engine::new().render(tmpl, M::new()).unwrap_err();
298 println!("[{}]\n template: {:?}\n error: {}\n", label, tmpl, err);
299 }
300 let err = Engine::new()
301 .with_strict()
302 .render("{missing}", M::new())
303 .unwrap_err();
304 println!("[strict undefined]\n error: {}\n", err);
305 }
306
307 #[test]
315 fn standalone_block_strips_its_line() {
316 let out = render("before\n{#if true}\nyes\n{/if}\nafter", &[]);
319 assert_eq!(out, "before\nyes\nafter");
320 }
321
322 #[test]
323 fn standalone_with_indentation() {
324 let out = render("before\n {#if true}\n yes\n {/if}\nafter", &[]);
326 assert_eq!(out, "before\n yes\nafter");
327 }
328
329 #[test]
330 fn inline_block_not_standalone() {
331 let out = render("a {#if true}b{/if} c", &[]);
333 assert_eq!(out, "a b c");
334 }
335
336 #[test]
337 fn standalone_each_strips_its_line() {
338 let items = Value::Array(vec![Value::from("x"), Value::from("y")]);
339 let out = render(
340 "list:\n{#each items as item}\n- {item}\n{/each}\ndone",
341 &[("items", items)],
342 );
343 assert_eq!(out, "list:\n- x\n- y\ndone");
344 }
345
346 #[test]
349 fn value_is_truthy() {
350 assert!(!Value::Null.is_truthy());
351 assert!(!Value::Bool(false).is_truthy());
352 assert!(Value::Bool(true).is_truthy());
353 assert!(!Value::Int(0).is_truthy());
354 assert!(Value::Int(1).is_truthy());
355 assert!(Value::Int(-1).is_truthy());
356 assert!(!Value::Float(0.0).is_truthy());
357 assert!(Value::Float(1.5).is_truthy());
358 assert!(!Value::String(String::new()).is_truthy());
359 assert!(Value::String("x".into()).is_truthy());
360 assert!(Value::Array(vec![]).is_truthy()); assert!(Value::Array(vec![Value::Int(1)]).is_truthy());
362 assert!(Value::Object(HashMap::new()).is_truthy());
363 }
364
365 #[test]
366 fn value_is_null() {
367 assert!(Value::Null.is_null());
368 assert!(!Value::Bool(false).is_null());
369 assert!(!Value::Int(0).is_null());
370 assert!(!Value::String(String::new()).is_null());
371 }
372
373 #[test]
374 fn value_type_names() {
375 assert_eq!(Value::Null.type_name(), "null");
376 assert_eq!(Value::Bool(true).type_name(), "bool");
377 assert_eq!(Value::Int(1).type_name(), "int");
378 assert_eq!(Value::Float(1.0).type_name(), "float");
379 assert_eq!(Value::String("x".into()).type_name(), "string");
380 assert_eq!(Value::Array(vec![]).type_name(), "array");
381 assert_eq!(Value::Object(HashMap::new()).type_name(), "object");
382 }
383
384 #[test]
385 fn value_display_string() {
386 assert_eq!(Value::Null.to_display_string(), "null");
387 assert_eq!(Value::Bool(true).to_display_string(), "true");
388 assert_eq!(Value::Bool(false).to_display_string(), "false");
389 assert_eq!(Value::Int(42).to_display_string(), "42");
390 assert_eq!(Value::Float(1.5).to_display_string(), "1.5");
391 assert_eq!(Value::Float(2.0).to_display_string(), "2");
393 assert_eq!(Value::String("hi".into()).to_display_string(), "hi");
394 assert_eq!(
395 Value::Array(vec![Value::Int(1), Value::Int(2)]).to_display_string(),
396 "1,2"
397 );
398 assert_eq!(
399 Value::Object(HashMap::new()).to_display_string(),
400 "[object Object]"
401 );
402 }
403
404 #[test]
405 fn value_json_string() {
406 assert_eq!(Value::Null.to_json_string(), "null");
407 assert_eq!(Value::Bool(true).to_json_string(), "true");
408 assert_eq!(Value::Bool(false).to_json_string(), "false");
409 assert_eq!(Value::Int(7).to_json_string(), "7");
410 assert_eq!(Value::Float(1.5).to_json_string(), "1.5");
411 assert_eq!(Value::String("hello".into()).to_json_string(), r#""hello""#);
412 assert_eq!(
414 Value::String("a\"b\\c\nd\re\t".into()).to_json_string(),
415 r#""a\"b\\c\nd\re\t""#
416 );
417 assert_eq!(
419 Value::Array(vec![Value::Int(1), Value::Bool(true), Value::Null]).to_json_string(),
420 "[1,true,null]"
421 );
422 let mut obj = HashMap::new();
424 obj.insert("z".to_string(), Value::Int(1));
425 obj.insert("a".to_string(), Value::Int(2));
426 assert_eq!(Value::Object(obj).to_json_string(), r#"{"a":2,"z":1}"#);
427 }
428
429 #[test]
430 fn value_length_and_is_empty() {
431 assert_eq!(Value::String("hello".into()).length(), Some(5));
432 assert_eq!(
433 Value::Array(vec![Value::Int(1), Value::Int(2)]).length(),
434 Some(2)
435 );
436 assert_eq!(Value::Object(HashMap::new()).length(), Some(0));
437 assert_eq!(Value::Null.length(), None);
438 assert_eq!(Value::Int(1).length(), None);
439
440 assert!(Value::String(String::new()).is_empty());
441 assert!(!Value::String("x".into()).is_empty());
442 assert!(Value::Array(vec![]).is_empty());
443 assert!(!Value::Array(vec![Value::Null]).is_empty());
444 assert!(Value::Object(HashMap::new()).is_empty());
445 assert!(!Value::Null.is_empty());
447 assert!(!Value::Int(0).is_empty());
448 assert!(!Value::Bool(false).is_empty());
449 }
450
451 #[test]
452 fn value_html_escape_fn() {
453 use crate::value::html_escape;
454 assert_eq!(
455 html_escape(r#"<div class="x">&it's</div>"#),
456 "<div class="x">&it's</div>"
457 );
458 assert_eq!(html_escape("no specials"), "no specials");
459 }
460
461 #[test]
462 fn value_urlencode_fn() {
463 use crate::value::urlencode;
464 assert_eq!(urlencode("hello world"), "hello%20world");
465 assert_eq!(urlencode("a-b_c.d~e"), "a-b_c.d~e"); assert_eq!(urlencode("a+b=c&d"), "a%2Bb%3Dc%26d");
467 assert_eq!(urlencode(""), "");
468 }
469
470 #[test]
471 fn value_from_impls() {
472 assert_eq!(Value::from(true), Value::Bool(true));
473 assert_eq!(Value::from(false), Value::Bool(false));
474 assert_eq!(Value::from(42i64), Value::Int(42));
475 assert_eq!(Value::from(1.5f64), Value::Float(1.5));
476 assert_eq!(Value::from("hi"), Value::String("hi".into()));
477 assert_eq!(Value::from("hi".to_string()), Value::String("hi".into()));
478 assert_eq!(
479 Value::from(vec![Value::Null]),
480 Value::Array(vec![Value::Null])
481 );
482 let mut m = HashMap::new();
483 m.insert("k".to_string(), Value::Int(1));
484 assert!(matches!(Value::from(m), Value::Object(_)));
485 }
486
487 #[test]
488 fn value_display_trait() {
489 assert_eq!(format!("{}", Value::Int(99)), "99");
490 assert_eq!(format!("{}", Value::from("hi")), "hi");
491 }
492
493 #[test]
496 fn error_display_variants() {
497 use crate::error::{Error, Span};
498 let lex = Error::LexError {
499 message: "bad token".into(),
500 span: Span::new(3, 7, 0),
501 };
502 assert!(lex.to_string().contains("Lex error at 3:7: bad token"));
503 let parse = Error::ParseError {
504 message: "bad syntax".into(),
505 span: Span::unknown(),
506 };
507 assert!(parse.to_string().contains("Parse error at 0:0: bad syntax"));
508 let render = Error::RenderError {
509 message: "bad render".into(),
510 };
511 assert!(render.to_string().contains("Render error: bad render"));
512 }
513
514 #[test]
517 fn add_two_strings() {
518 let out = render(
519 "{a + ' ' + b}",
520 &[("a", Value::from("Hello")), ("b", Value::from("World"))],
521 );
522 assert_eq!(out, "Hello World");
523 }
524
525 #[test]
526 fn add_int_and_string_coerces_to_string() {
527 let out = render("{count + ' items'}", &[("count", Value::Int(5))]);
528 assert_eq!(out, "5 items");
529 }
530
531 #[test]
532 fn add_null_and_string_coerces() {
533 let out = render("{x + ' end'}", &[("x", Value::Null)]);
534 assert_eq!(out, "null end");
535 }
536
537 #[test]
540 fn strict_null_property_access_errors() {
541 let err = Engine::new()
542 .with_strict()
543 .render("{x.y}", ctx(&[("x", Value::Null)]))
544 .unwrap_err();
545 assert!(err.to_string().contains("null"));
546 }
547
548 #[test]
549 fn strict_missing_property_errors() {
550 let mut obj = HashMap::new();
551 obj.insert("a".to_string(), Value::Int(1));
552 let err = Engine::new()
553 .with_strict()
554 .render("{x.b}", ctx(&[("x", Value::Object(obj))]))
555 .unwrap_err();
556 assert!(err.to_string().contains("not found"));
557 }
558
559 #[test]
560 fn strict_array_out_of_bounds_errors() {
561 let arr = Value::Array(vec![Value::Int(1)]);
562 let err = Engine::new()
563 .with_strict()
564 .render("{a[5]}", ctx(&[("a", arr)]))
565 .unwrap_err();
566 assert!(err.to_string().contains("out of bounds"));
567 }
568
569 #[test]
570 fn lax_out_of_bounds_returns_null() {
571 let arr = Value::Array(vec![Value::Int(1)]);
572 let out = render("{a[5] ?? 'none'}", &[("a", arr)]);
573 assert_eq!(out, "none");
574 }
575
576 #[test]
577 fn lax_null_member_returns_null() {
578 let out = render("{x.y ?? 'nil'}", &[("x", Value::Null)]);
579 assert_eq!(out, "nil");
580 }
581
582 #[test]
583 fn member_access_non_object_strict_errors() {
584 let err = Engine::new()
585 .with_strict()
586 .render("{a.b}", ctx(&[("a", Value::Int(1))]))
587 .unwrap_err();
588 assert!(err.to_string().contains("Cannot access property"));
589 }
590
591 #[test]
592 fn member_access_non_object_lax_is_null() {
593 let out = render("{a.b ?? 'nil'}", &[("a", Value::Int(1))]);
594 assert_eq!(out, "nil");
595 }
596
597 #[test]
600 fn division_by_zero_int() {
601 let err = Engine::new()
602 .render("{a / 0}", ctx(&[("a", Value::Int(5))]))
603 .unwrap_err();
604 assert!(err.to_string().contains("Division by zero"));
605 }
606
607 #[test]
608 fn division_by_zero_float() {
609 let err = Engine::new()
610 .render("{a / 0.0}", ctx(&[("a", Value::Float(1.0))]))
611 .unwrap_err();
612 assert!(err.to_string().contains("Division by zero"));
613 }
614
615 #[test]
616 fn subtraction_non_numbers_errors() {
617 let err = Engine::new()
618 .render(
619 "{a - b}",
620 ctx(&[("a", Value::from("x")), ("b", Value::from("y"))]),
621 )
622 .unwrap_err();
623 assert!(err.to_string().contains("Arithmetic requires numbers"));
624 }
625
626 #[test]
627 fn unary_neg_non_number_errors() {
628 let err = Engine::new()
629 .render("{-a}", ctx(&[("a", Value::from("x"))]))
630 .unwrap_err();
631 assert!(err.to_string().contains("negate"));
632 }
633
634 #[test]
637 fn compare_incompatible_types_errors() {
638 let err = Engine::new()
639 .render(
640 "{a < b}",
641 ctx(&[("a", Value::Int(1)), ("b", Value::from("x"))]),
642 )
643 .unwrap_err();
644 assert!(err.to_string().contains("Cannot compare"));
645 }
646
647 #[test]
648 fn compare_strings_lexicographically() {
649 assert_eq!(
650 render(
651 "{a < b ? 'yes' : 'no'}",
652 &[("a", Value::from("abc")), ("b", Value::from("xyz"))]
653 ),
654 "yes"
655 );
656 }
657
658 #[test]
659 fn compare_int_and_float() {
660 assert_eq!(
661 render(
662 "{a >= b ? 'yes' : 'no'}",
663 &[("a", Value::Int(2)), ("b", Value::Float(1.5))]
664 ),
665 "yes"
666 );
667 }
668
669 #[test]
672 fn test_undefined() {
673 assert_eq!(render("{#if x is undefined}yes{:else}no{/if}", &[]), "yes");
674 assert_eq!(
675 render(
676 "{#if x is undefined}yes{:else}no{/if}",
677 &[("x", Value::Int(1))]
678 ),
679 "no"
680 );
681 }
682
683 #[test]
684 fn test_none() {
685 assert_eq!(
686 render("{#if x is none}yes{/if}", &[("x", Value::Null)]),
687 "yes"
688 );
689 assert_eq!(
690 render("{#if x is none}no{:else}yes{/if}", &[("x", Value::Int(1))]),
691 "yes"
692 );
693 }
694
695 #[test]
696 fn test_truthy_falsy() {
697 assert_eq!(
698 render("{#if x is truthy}yes{/if}", &[("x", Value::Int(1))]),
699 "yes"
700 );
701 assert_eq!(
702 render(
703 "{#if x is truthy}no{:else}yes{/if}",
704 &[("x", Value::Int(0))]
705 ),
706 "yes"
707 );
708 assert_eq!(
709 render("{#if x is falsy}yes{/if}", &[("x", Value::Int(0))]),
710 "yes"
711 );
712 assert_eq!(
713 render(
714 "{#if x is falsy}no{:else}yes{/if}",
715 &[("x", Value::Bool(true))]
716 ),
717 "yes"
718 );
719 }
720
721 #[test]
722 fn test_string_number_iterable() {
723 assert_eq!(
724 render("{#if x is string}yes{/if}", &[("x", Value::from("hi"))]),
725 "yes"
726 );
727 assert_eq!(
728 render(
729 "{#if x is string}no{:else}yes{/if}",
730 &[("x", Value::Int(1))]
731 ),
732 "yes"
733 );
734 assert_eq!(
735 render("{#if x is number}yes{/if}", &[("x", Value::Int(1))]),
736 "yes"
737 );
738 assert_eq!(
739 render("{#if x is number}yes{/if}", &[("x", Value::Float(1.0))]),
740 "yes"
741 );
742 assert_eq!(
743 render(
744 "{#if x is iterable}yes{/if}",
745 &[("x", Value::Array(vec![]))]
746 ),
747 "yes"
748 );
749 assert_eq!(
750 render(
751 "{#if x is iterable}no{:else}yes{/if}",
752 &[("x", Value::Int(1))]
753 ),
754 "yes"
755 );
756 }
757
758 #[test]
759 fn test_odd_even_non_number_errors() {
760 let err = Engine::new()
761 .render("{#if x is odd}y{/if}", ctx(&[("x", Value::from("a"))]))
762 .unwrap_err();
763 assert!(err.to_string().contains("odd"));
764 let err = Engine::new()
765 .render("{#if x is even}y{/if}", ctx(&[("x", Value::from("a"))]))
766 .unwrap_err();
767 assert!(err.to_string().contains("even"));
768 }
769
770 #[test]
771 fn test_unknown_in_strict_mode_errors() {
772 let err = Engine::new()
773 .with_strict()
774 .render("{#if x is foobar}y{/if}", ctx(&[("x", Value::Int(1))]))
775 .unwrap_err();
776 assert!(err.to_string().contains("Unknown test"));
777 }
778
779 #[test]
780 fn test_is_not() {
781 assert_eq!(
782 render("{#if x is not empty}yes{/if}", &[("x", Value::from("hi"))]),
783 "yes"
784 );
785 assert_eq!(
786 render(
787 "{#if x is not empty}no{:else}yes{/if}",
788 &[("x", Value::from(""))]
789 ),
790 "yes"
791 );
792 assert_eq!(render("{#if x is not defined}yes{/if}", &[]), "yes");
794 assert_eq!(
796 render(
797 "{#if x is not defined}yes{:else}no{/if}",
798 &[("x", Value::Null)]
799 ),
800 "yes"
801 );
802 }
803
804 #[test]
807 fn in_string_substring() {
808 let out = render(
809 "{#if x in s}yes{:else}no{/if}",
810 &[("x", Value::from("ell")), ("s", Value::from("hello"))],
811 );
812 assert_eq!(out, "yes");
813 }
814
815 #[test]
816 fn in_object_checks_keys() {
817 let mut obj = HashMap::new();
818 obj.insert("name".to_string(), Value::Int(1));
819 assert_eq!(
820 render("{#if 'name' in o}yes{/if}", &[("o", Value::Object(obj))]),
821 "yes"
822 );
823 }
824
825 #[test]
826 fn not_in_array() {
827 let arr = Value::Array(vec![Value::from("a"), Value::from("b")]);
828 assert_eq!(
829 render("{#if 'c' not in items}yes{/if}", &[("items", arr)]),
830 "yes"
831 );
832 }
833
834 #[test]
835 fn in_incompatible_type_errors() {
836 let err = Engine::new()
837 .render("{#if 1 in x}y{/if}", ctx(&[("x", Value::Int(42))]))
838 .unwrap_err();
839 assert!(err.to_string().contains("'in' operator"));
840 }
841
842 #[test]
845 fn index_negative_wraps() {
846 let arr = Value::Array(vec![Value::Int(1), Value::Int(2), Value::Int(3)]);
847 assert_eq!(render("{a[-1]}", &[("a", arr)]), "3");
848 }
849
850 #[test]
851 fn index_object_by_string_key() {
852 let mut obj = HashMap::new();
853 obj.insert("key".to_string(), Value::from("val"));
854 assert_eq!(render("{o['key']}", &[("o", Value::Object(obj))]), "val");
855 }
856
857 #[test]
858 fn index_non_integer_on_array_errors() {
859 let arr = Value::Array(vec![Value::Int(1)]);
860 let err = Engine::new()
861 .render("{a['x']}", ctx(&[("a", arr)]))
862 .unwrap_err();
863 assert!(err.to_string().contains("integer"));
864 }
865
866 #[test]
867 fn index_into_null_strict_errors() {
868 let err = Engine::new()
869 .with_strict()
870 .render("{a[0]}", ctx(&[("a", Value::Null)]))
871 .unwrap_err();
872 assert!(err.to_string().contains("null"));
873 }
874
875 #[test]
876 fn index_into_non_collection_errors() {
877 let err = Engine::new()
878 .render("{a[0]}", ctx(&[("a", Value::Int(5))]))
879 .unwrap_err();
880 assert!(err.to_string().contains("Cannot index"));
881 }
882
883 #[test]
886 fn each_non_array_errors() {
887 let err = Engine::new()
888 .render(
889 "{#each x as item}{item}{/each}",
890 ctx(&[("x", Value::Int(1))]),
891 )
892 .unwrap_err();
893 assert!(err.to_string().contains("array"));
894 }
895
896 #[test]
897 fn each_null_iterable_uses_else() {
898 let out = render(
899 "{#each x as item}{item}{:else}empty{/each}",
900 &[("x", Value::Null)],
901 );
902 assert_eq!(out, "empty");
903 }
904
905 #[test]
908 fn render_unknown_snippet_errors() {
909 let err = Engine::new()
910 .render("{@render missing()}", ctx(&[]))
911 .unwrap_err();
912 assert!(err.to_string().contains("Unknown snippet"));
913 }
914
915 #[test]
916 fn render_wrong_arg_count_errors() {
917 let err = Engine::new()
918 .render("{#snippet foo(a, b)}ok{/snippet}{@render foo(1)}", ctx(&[]))
919 .unwrap_err();
920 assert!(err.to_string().contains("expects"));
921 }
922
923 #[test]
926 fn include_not_found_errors() {
927 let err = Engine::new()
928 .render("{@include 'no_such.html'}", ctx(&[]))
929 .unwrap_err();
930 assert!(
931 err.to_string().to_lowercase().contains("template")
932 || err.to_string().contains("not found")
933 );
934 }
935
936 #[test]
939 fn filter_replace() {
940 let out = render("{s | replace('o', '0')}", &[("s", Value::from("foobar"))]);
941 assert_eq!(out, "f00bar");
942 }
943
944 #[test]
945 fn filter_split_and_join() {
946 let out = render(
947 "{s | split(',') | join(' ')}",
948 &[("s", Value::from("a,b,c"))],
949 );
950 assert_eq!(out, "a b c");
951 }
952
953 #[test]
954 fn filter_sort_and_reverse() {
955 let arr = Value::Array(vec![Value::from("c"), Value::from("a"), Value::from("b")]);
956 assert_eq!(
957 render("{items | sort | join(',')}", &[("items", arr.clone())]),
958 "a,b,c"
959 );
960 assert_eq!(
961 render("{items | reverse | join(',')}", &[("items", arr)]),
962 "b,a,c"
963 );
964 }
965
966 #[test]
967 fn filter_reverse_string() {
968 assert_eq!(render("{s | reverse}", &[("s", Value::from("abc"))]), "cba");
969 }
970
971 #[test]
972 fn filter_first_and_last() {
973 let arr = Value::Array(vec![Value::Int(10), Value::Int(20), Value::Int(30)]);
974 assert_eq!(render("{items | first}", &[("items", arr.clone())]), "10");
975 assert_eq!(render("{items | last}", &[("items", arr)]), "30");
976 }
977
978 #[test]
979 fn filter_first_last_empty_array() {
980 let arr = Value::Array(vec![]);
982 assert_eq!(render("{items | first}", &[("items", arr.clone())]), "null");
983 assert_eq!(render("{items | last}", &[("items", arr)]), "null");
984 }
985
986 #[test]
987 fn filter_json() {
988 let arr = Value::Array(vec![Value::Int(1), Value::Bool(false), Value::Null]);
989 assert_eq!(render("{v | json}", &[("v", arr)]), "[1,false,null]");
990 }
991
992 #[test]
993 fn filter_json_object() {
994 let mut obj = HashMap::new();
995 obj.insert("x".to_string(), Value::Int(1));
996 let out = Engine::new()
998 .render("{@html v | json}", ctx(&[("v", Value::Object(obj))]))
999 .unwrap();
1000 assert_eq!(out, r#"{"x":1}"#);
1001 }
1002
1003 #[test]
1004 fn filter_urlencode() {
1005 let out = render("{s | urlencode}", &[("s", Value::from("a b+c"))]);
1006 assert_eq!(out, "a%20b%2Bc");
1007 }
1008
1009 #[test]
1010 fn filter_escape_inside_html_tag() {
1011 let out = render("{@html s | escape}", &[("s", Value::from("<b>bold</b>"))]);
1012 assert_eq!(out, "<b>bold</b>");
1013 }
1014
1015 #[test]
1016 fn filter_unknown_errors() {
1017 let err = Engine::new()
1018 .render("{s | nosuchfilter}", ctx(&[("s", Value::from("x"))]))
1019 .unwrap_err();
1020 assert!(err.to_string().contains("Unknown filter"));
1021 }
1022
1023 #[test]
1024 fn filter_wrong_type_string_filters() {
1025 for tmpl in [
1026 "{n | upper}",
1027 "{n | lower}",
1028 "{n | capitalize}",
1029 "{n | trim}",
1030 "{n | truncate(5)}",
1031 "{n | split(',')}",
1032 "{n | urlencode}",
1033 ] {
1034 let err = Engine::new()
1035 .render(tmpl, ctx(&[("n", Value::Int(1))]))
1036 .unwrap_err();
1037 assert!(
1038 matches!(err, crate::Error::RenderError { .. }),
1039 "expected RenderError for {tmpl}"
1040 );
1041 }
1042 }
1043
1044 #[test]
1045 fn filter_wrong_type_collection_filters() {
1046 for tmpl in ["{n | sort}", "{n | join}", "{n | first}", "{n | last}"] {
1047 let err = Engine::new()
1048 .render(tmpl, ctx(&[("n", Value::from("x"))]))
1049 .unwrap_err();
1050 assert!(
1051 matches!(err, crate::Error::RenderError { .. }),
1052 "expected RenderError for {tmpl}"
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn filter_reverse_wrong_type_errors() {
1059 let err = Engine::new()
1060 .render("{n | reverse}", ctx(&[("n", Value::Int(1))]))
1061 .unwrap_err();
1062 assert!(matches!(err, crate::Error::RenderError { .. }));
1063 }
1064
1065 #[test]
1066 fn filter_round_wrong_type_errors() {
1067 let err = Engine::new()
1068 .render("{n | round}", ctx(&[("n", Value::from("x"))]))
1069 .unwrap_err();
1070 assert!(matches!(err, crate::Error::RenderError { .. }));
1071 }
1072
1073 #[test]
1074 fn filter_length_wrong_type_errors() {
1075 let err = Engine::new()
1076 .render("{n | length}", ctx(&[("n", Value::Int(1))]))
1077 .unwrap_err();
1078 assert!(matches!(err, crate::Error::RenderError { .. }));
1079 }
1080
1081 #[test]
1084 fn render_name_via_loader() {
1085 let engine = Engine::new().with_template_loader(|name| match name {
1086 "greet" => Ok("Hello, {who}!".to_string()),
1087 other => Err(crate::Error::RenderError {
1088 message: format!("not found: {other}"),
1089 }),
1090 });
1091 let mut c = HashMap::new();
1092 c.insert("who".to_string(), Value::from("World"));
1093 assert_eq!(engine.render_name("greet", c).unwrap(), "Hello, World!");
1094 }
1095
1096 #[test]
1097 fn render_name_loader_missing_errors() {
1098 let engine = Engine::new().with_template_loader(|name| {
1099 Err(crate::Error::RenderError {
1100 message: format!("not found: {name}"),
1101 })
1102 });
1103 let err = engine.render_name("missing", HashMap::new()).unwrap_err();
1104 assert!(err.to_string().contains("not found"));
1105 }
1106
1107 #[test]
1108 fn compile_and_render_template() {
1109 let engine = Engine::new();
1110 let tpl = engine.compile("Hello, {name}!").unwrap();
1111 let mut c = HashMap::new();
1112 c.insert("name".to_string(), Value::from("World"));
1113 let out = engine.render_template(&tpl, c).unwrap();
1114 assert_eq!(out, "Hello, World!");
1115 }
1116
1117 #[test]
1120 fn lex_unterminated_string_errors() {
1121 let err = Engine::new().render("{\"unclosed}", ctx(&[])).unwrap_err();
1122 assert!(matches!(err, crate::Error::LexError { .. }));
1123 }
1124
1125 #[test]
1126 fn lex_unknown_escape_errors() {
1127 let err = Engine::new().render("{\"\\z\"}", ctx(&[])).unwrap_err();
1128 assert!(err.to_string().contains("escape"));
1129 }
1130
1131 #[test]
1132 fn lex_unicode_escape() {
1133 assert_eq!(render("{\"\\u{41}\"}", &[]), "A");
1135 assert_eq!(render("{\"\\u{1F600}\"}", &[]), "😀");
1137 }
1138
1139 #[test]
1140 fn lex_lone_ampersand_errors() {
1141 let err = Engine::new()
1142 .render(
1143 "{a & b}",
1144 ctx(&[("a", Value::Int(1)), ("b", Value::Int(2))]),
1145 )
1146 .unwrap_err();
1147 assert!(err.to_string().contains("&&"));
1148 }
1149
1150 #[test]
1151 fn lex_unclosed_comment_errors() {
1152 let err = Engine::new().render("{! unclosed", ctx(&[])).unwrap_err();
1153 assert!(matches!(err, crate::Error::LexError { .. }));
1154 }
1155
1156 #[test]
1157 fn lex_unclosed_raw_block_errors() {
1158 let err = Engine::new()
1159 .render("{#raw}unclosed", ctx(&[]))
1160 .unwrap_err();
1161 assert!(matches!(err, crate::Error::LexError { .. }));
1162 }
1163
1164 #[test]
1165 fn lex_float_scientific_notation() {
1166 assert_eq!(render("{1.5e1}", &[]), "15");
1167 assert_eq!(render("{1.0e2}", &[]), "100");
1168 }
1169
1170 #[test]
1173 fn parse_unclosed_if_errors() {
1174 let err = Engine::new()
1175 .render("{#if true}unclosed", ctx(&[]))
1176 .unwrap_err();
1177 assert!(matches!(err, crate::Error::ParseError { .. }));
1178 }
1179
1180 #[test]
1181 fn parse_unknown_special_tag_errors() {
1182 let err = Engine::new().render("{@unknown}", ctx(&[])).unwrap_err();
1183 assert!(matches!(err, crate::Error::ParseError { .. }));
1184 }
1185
1186 #[test]
1187 fn parse_array_literal() {
1188 let out = render("{[1, 2, 3] | join(',')}", &[]);
1189 assert_eq!(out, "1,2,3");
1190 }
1191
1192 #[test]
1193 fn parse_nested_array_literal() {
1194 let out = render("{['a', 'b', 'c'] | length}", &[]);
1195 assert_eq!(out, "3");
1196 }
1197}