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/// Build a `HashMap<String, Value>` context from key `=>` value pairs.
13///
14/// The map can be passed directly to [`Engine::render`]. For nested objects,
15/// wrap an inner `context!` call in [`Value::from`].
16///
17/// ```rust
18/// use gracile_core::{Engine, Value, context};
19///
20/// let ctx = context! {
21///     name => "Alice",
22///     score => 42,
23///     active => true,
24/// };
25/// let out = Engine::new().render("{= name}: {= score}", ctx).unwrap();
26/// assert_eq!(out, "Alice: 42");
27/// ```
28///
29/// Nested example:
30/// ```rust
31/// use gracile_core::{Value, context};
32///
33/// let ctx = context! {
34///     user => Value::from(context! { name => "Alice", age => 30 }),
35/// };
36/// ```
37#[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    // ── Interpolation ──────────────────────────────────────────────────────
65
66    #[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, "&lt;b&gt;hi&lt;/b&gt;");
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    // ── Control flow ──────────────────────────────────────────────────────
100
101    #[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    // ── Each block ────────────────────────────────────────────────────────
132
133    #[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    // ── Snippets ──────────────────────────────────────────────────────────
186
187    #[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    // ── Raw block ─────────────────────────────────────────────────────────
197
198    #[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    // ── Sigil escapes ──────────────────────────────────────────────────────
205
206    #[test]
207    fn escape_expr_sigil() {
208        // `{\=` outputs a literal `{=` without triggering interpolation.
209        let out = render(r"{\= name}", &[("name", Value::from("Alice"))]);
210        assert_eq!(out, "{= name}");
211    }
212
213    #[test]
214    fn escape_raw_sigil() {
215        // `{\~` outputs a literal `{~` without triggering raw interpolation.
216        let out = render(r"{\~ name}", &[("name", Value::from("Alice"))]);
217        assert_eq!(out, "{~ name}");
218    }
219
220    // ── Const tag ─────────────────────────────────────────────────────────
221
222    #[test]
223    fn const_tag() {
224        let out = render("{@const x = 42}{= x}", &[]);
225        assert_eq!(out, "42");
226    }
227
228    // ── Expressions ───────────────────────────────────────────────────────
229
230    #[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    // ── Filters ───────────────────────────────────────────────────────────
267
268    #[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    // ── is tests ──────────────────────────────────────────────────────────
324
325    #[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    // ── in operator ───────────────────────────────────────────────────────
343
344    #[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    // ── Error messages ────────────────────────────────────────────────────
355
356    #[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    // ── Standalone line stripping ─────────────────────────────────────────
377    //
378    // When a block tag is the only thing on a line (optionally preceded by
379    // spaces/tabs), that entire line is silently removed from the output.
380    // This mirrors the Handlebars "standalone line" rule and means no explicit
381    // trim modifier syntax is needed.
382
383    #[test]
384    fn standalone_block_strips_its_line() {
385        // The {#if} and {/if} tags each occupy their own line; both lines should
386        // disappear, leaving only the inner content.
387        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        // Indentation before the tag is also stripped.
394        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        // A block tag that shares a line with content is NOT standalone.
401        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    // ── Value unit tests ──────────────────────────────────────────────────
416
417    #[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()); // empty array is truthy
430        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        // Whole-number floats display without decimal point
461        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        // String escaping
482        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        // Array
487        assert_eq!(
488            Value::Array(vec![Value::Int(1), Value::Bool(true), Value::Null]).to_json_string(),
489            "[1,true,null]"
490        );
491        // Object (keys are sorted for determinism)
492        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        // Non-string/array/object always returns false
515        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            "&lt;div class=&quot;x&quot;&gt;&amp;it&#x27;s&lt;/div&gt;"
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"); // unreserved chars pass through
535        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    // ── Error display ─────────────────────────────────────────────────────
563
564    #[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    // ── Context-aware `+` ────────────────────────────────────────────────
584
585    #[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    // ── Strict mode ───────────────────────────────────────────────────────
607
608    #[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    // ── Arithmetic errors ─────────────────────────────────────────────────
667
668    #[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    // ── Comparison ────────────────────────────────────────────────────────
704
705    #[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    // ── is tests ──────────────────────────────────────────────────────────
739
740    #[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        // x absent → value is null → defined=false → is not defined=true
862        assert_eq!(render("{#if x is not defined}yes{/if}", &[]), "yes");
863        // x=Null → defined=false → is not defined=true → renders the if-body
864        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    // ── Membership ────────────────────────────────────────────────────────
874
875    #[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    // ── Index access ──────────────────────────────────────────────────────
912
913    #[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    // ── Each with non-array ───────────────────────────────────────────────
953
954    #[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    // ── Snippet / render errors ───────────────────────────────────────────
975
976    #[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    // ── Include errors ────────────────────────────────────────────────────
993
994    #[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    // ── Filters ───────────────────────────────────────────────────────────
1037
1038    #[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        // first/last on an empty array returns null, which renders as "null"
1084        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        // json output contains quotes → use {~ } to bypass auto-escaping
1103        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, "&lt;b&gt;bold&lt;/b&gt;");
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    // ── render_name / compile / loader ────────────────────────────────────
1193
1194    #[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    // ── Lexer error paths ─────────────────────────────────────────────────
1229
1230    #[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        // \u{41} = 'A'
1247        assert_eq!(render("{= \"\\u{41}\"}", &[]), "A");
1248        // emoji
1249        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    // ── Parser error paths ────────────────────────────────────────────────
1284
1285    #[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    // ── context! macro ────────────────────────────────────────────────────────
1312
1313    #[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    // ── Serde feature tests ───────────────────────────────────────────────────
1334
1335    #[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}