Skip to main content

gracile_core/
lib.rs

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    // ── Interpolation ──────────────────────────────────────────────────────
31
32    #[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, "&lt;b&gt;hi&lt;/b&gt;");
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    // ── Control flow ──────────────────────────────────────────────────────
66
67    #[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    // ── Each block ────────────────────────────────────────────────────────
98
99    #[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    // ── Snippets ──────────────────────────────────────────────────────────
139
140    #[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    // ── Raw block ─────────────────────────────────────────────────────────
150
151    #[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    // ── Const tag ─────────────────────────────────────────────────────────
158
159    #[test]
160    fn const_tag() {
161        let out = render("{@const x = 42}{x}", &[]);
162        assert_eq!(out, "42");
163    }
164
165    // ── Expressions ───────────────────────────────────────────────────────
166
167    #[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    // ── Filters ───────────────────────────────────────────────────────────
201
202    #[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    // ── is tests ──────────────────────────────────────────────────────────
255
256    #[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    // ── in operator ───────────────────────────────────────────────────────
274
275    #[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    // ── Error messages ────────────────────────────────────────────────────
286
287    #[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    // ── Standalone line stripping ─────────────────────────────────────────
308    //
309    // When a block tag is the only thing on a line (optionally preceded by
310    // spaces/tabs), that entire line is silently removed from the output.
311    // This mirrors the Handlebars "standalone line" rule and means no explicit
312    // trim modifier syntax is needed.
313
314    #[test]
315    fn standalone_block_strips_its_line() {
316        // The {#if} and {/if} tags each occupy their own line; both lines should
317        // disappear, leaving only the inner content.
318        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        // Indentation before the tag is also stripped.
325        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        // A block tag that shares a line with content is NOT standalone.
332        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    // ── Value unit tests ──────────────────────────────────────────────────
347
348    #[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()); // empty array is truthy
361        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        // Whole-number floats display without decimal point
392        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        // String escaping
413        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        // Array
418        assert_eq!(
419            Value::Array(vec![Value::Int(1), Value::Bool(true), Value::Null]).to_json_string(),
420            "[1,true,null]"
421        );
422        // Object (keys are sorted for determinism)
423        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        // Non-string/array/object always returns false
446        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            "&lt;div class=&quot;x&quot;&gt;&amp;it&#x27;s&lt;/div&gt;"
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"); // unreserved chars pass through
466        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    // ── Error display ─────────────────────────────────────────────────────
494
495    #[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    // ── Context-aware `+` ────────────────────────────────────────────────
515
516    #[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    // ── Strict mode ───────────────────────────────────────────────────────
538
539    #[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    // ── Arithmetic errors ─────────────────────────────────────────────────
598
599    #[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    // ── Comparison ────────────────────────────────────────────────────────
635
636    #[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    // ── is tests ──────────────────────────────────────────────────────────
670
671    #[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        // x absent → value is null → defined=false → is not defined=true
793        assert_eq!(render("{#if x is not defined}yes{/if}", &[]), "yes");
794        // x=Null → defined=false → is not defined=true → renders the if-body
795        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    // ── Membership ────────────────────────────────────────────────────────
805
806    #[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    // ── Index access ──────────────────────────────────────────────────────
843
844    #[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    // ── Each with non-array ───────────────────────────────────────────────
884
885    #[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    // ── Snippet / render errors ───────────────────────────────────────────
906
907    #[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    // ── Include errors ────────────────────────────────────────────────────
924
925    #[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    // ── Filters ───────────────────────────────────────────────────────────
937
938    #[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        // first/last on an empty array returns null, which renders as "null"
981        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        // json output contains quotes → use {@html} to bypass auto-escaping
997        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, "&lt;b&gt;bold&lt;/b&gt;");
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    // ── render_name / compile / loader ────────────────────────────────────
1082
1083    #[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    // ── Lexer error paths ─────────────────────────────────────────────────
1118
1119    #[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        // \u{41} = 'A'
1134        assert_eq!(render("{\"\\u{41}\"}", &[]), "A");
1135        // emoji
1136        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    // ── Parser error paths ────────────────────────────────────────────────
1171
1172    #[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}