Skip to main content

gracile_core/
renderer.rs

1//! Template renderer that evaluates the AST against a context to produce output.
2
3use std::collections::HashMap;
4
5use crate::ast::*;
6use crate::error::{Error, Result};
7use crate::value::{Value, html_escape, urlencode};
8
9// ─── Filter / Loader ──────────────────────────────────────────────────────────
10
11pub type FilterFn =
12    Box<dyn Fn(Value, Vec<Value>) -> crate::error::Result<Value> + Send + Sync + 'static>;
13
14/// Signature for a template loader function.
15///
16/// Receives a template name and returns the template source, or an error if
17/// the template cannot be found.  This lets you configure an `Engine` to load
18/// templates from the filesystem, a database, a map, or any other source
19/// without having to pre-register every template up front.
20///
21/// ```rust
22/// use gracile_core::{Engine, Value};
23/// use std::collections::HashMap;
24///
25/// let engine = Engine::new()
26///     .with_template_loader(|name| {
27///         match name {
28///             "greeting" => Ok("Hello, {name}!".to_string()),
29///             other => Err(gracile_core::Error::RenderError {
30///                 message: format!("unknown template '{}'", other),
31///             }),
32///         }
33///     });
34///
35/// let mut ctx = HashMap::new();
36/// ctx.insert("name".to_string(), Value::from("World"));
37/// let out = engine.render_name("greeting", ctx).unwrap();
38/// assert_eq!(out, "Hello, World!");
39/// ```
40pub type LoaderFn = Box<dyn Fn(&str) -> crate::error::Result<String> + Send + Sync + 'static>;
41
42// ─── Engine ───────────────────────────────────────────────────────────────────
43
44/// The Gracile templating engine.
45///
46/// ```rust
47/// use gracile_core::{Engine, Value};
48/// use std::collections::HashMap;
49///
50/// let mut ctx = HashMap::new();
51/// ctx.insert("name".to_string(), Value::from("World"));
52/// let output = Engine::new().render("Hello, {name}!", ctx).unwrap();
53/// assert_eq!(output, "Hello, World!");
54/// ```
55/// Signature for user-supplied filter functions.
56///
57/// Receives the piped value and any parenthesised arguments, returns the
58/// transformed value or an error.
59///
60/// ```rust
61/// use gracile_core::{Engine, Value, FilterFn};
62///
63/// let engine = Engine::new()
64///     .register_filter("shout", |val, _args| {
65///         match val {
66///             Value::String(s) => Ok(Value::String(format!("{}!!!", s.to_uppercase()))),
67///             other => Ok(other),
68///         }
69///     });
70/// ```
71pub struct Engine {
72    /// Raise an error on undefined variables / properties instead of returning null.
73    pub strict: bool,
74    /// Registered templates (name → source) available to `{@include}`.
75    templates: HashMap<String, String>,
76    /// User-registered filters, checked before built-ins.
77    filters: HashMap<String, FilterFn>,
78    /// Optional loader called when a template name is not in `templates`.
79    loader: Option<LoaderFn>,
80}
81
82impl Default for Engine {
83    fn default() -> Self {
84        Engine::new()
85    }
86}
87
88impl Engine {
89    pub fn new() -> Self {
90        Engine {
91            strict: false,
92            templates: HashMap::new(),
93            filters: HashMap::new(),
94            loader: None,
95        }
96    }
97
98    /// Enable strict mode (undefined variables are errors, not null).
99    pub fn with_strict(mut self) -> Self {
100        self.strict = true;
101        self
102    }
103
104    /// Register a custom filter function.
105    ///
106    /// User filters take precedence over built-ins, so you can override them if
107    /// needed. The filter receives the piped value and any parenthesised
108    /// arguments evaluated to `Value`.
109    pub fn register_filter<F>(mut self, name: impl Into<String>, f: F) -> Self
110    where
111        F: Fn(Value, Vec<Value>) -> crate::error::Result<Value> + Send + Sync + 'static,
112    {
113        self.filters.insert(name.into(), Box::new(f));
114        self
115    }
116
117    /// Register a named template that can be referenced by `{@include "name"}`.
118    pub fn register_template(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
119        self.templates.insert(name.into(), source.into());
120        self
121    }
122
123    /// Set a loader function that is called when a template name is not found
124    /// in the pre-registered map.
125    ///
126    /// This lets you lazily load templates from the filesystem, a cache, or
127    /// any other source without having to pre-register them all up front.
128    ///
129    /// ```rust
130    /// use gracile_core::Engine;
131    /// use std::collections::HashMap;
132    ///
133    /// # std::fs::write("/tmp/hello.html", "Hello, {name}!").unwrap();
134    /// let engine = Engine::new()
135    ///     .with_template_loader(|name| {
136    ///         std::fs::read_to_string(format!("/tmp/{}", name))
137    ///             .map_err(|e| gracile_core::Error::RenderError {
138    ///                 message: format!("cannot load '{}': {}", name, e),
139    ///             })
140    ///     });
141    /// ```
142    pub fn with_template_loader<F>(mut self, loader: F) -> Self
143    where
144        F: Fn(&str) -> crate::error::Result<String> + Send + Sync + 'static,
145    {
146        self.loader = Some(Box::new(loader));
147        self
148    }
149
150    /// Resolve a template by name (pre-registered or via the loader) and render it.
151    ///
152    /// ```rust
153    /// use gracile_core::{Engine, Value};
154    /// use std::collections::HashMap;
155    ///
156    /// let engine = Engine::new()
157    ///     .with_template_loader(|name| match name {
158    ///         "greet" => Ok("Hello, {who}!".to_string()),
159    ///         other => Err(gracile_core::Error::RenderError {
160    ///             message: format!("unknown template '{}'", other),
161    ///         }),
162    ///     });
163    ///
164    /// let mut ctx = HashMap::new();
165    /// ctx.insert("who".to_string(), Value::from("World"));
166    /// assert_eq!(engine.render_name("greet", ctx).unwrap(), "Hello, World!");
167    /// ```
168    pub fn render_name(&self, name: &str, context: HashMap<String, Value>) -> Result<String> {
169        let source = self.resolve_template(name)?;
170        self.render(&source, context)
171    }
172
173    /// Resolve a template name to its source string.
174    pub(crate) fn resolve_template(&self, name: &str) -> Result<String> {
175        if let Some(src) = self.templates.get(name) {
176            return Ok(src.clone());
177        }
178        if let Some(ref loader) = self.loader {
179            return loader(name);
180        }
181        Err(Error::RenderError {
182            message: format!("Template '{}' not found in engine registry", name),
183        })
184    }
185
186    /// Render `source` against `context` and return the produced string.
187    pub fn render(&self, source: &str, context: HashMap<String, Value>) -> Result<String> {
188        let tokens = crate::lexer::tokenize(source)?;
189        let template = crate::parser::parse(tokens)?;
190        let mut renderer = Renderer::new(self, context);
191        renderer.render_template(&template)
192    }
193
194    /// Pre-compile a source string into a `Template` AST (for repeated rendering).
195    pub fn compile(&self, source: &str) -> Result<Template> {
196        let tokens = crate::lexer::tokenize(source)?;
197        crate::parser::parse(tokens)
198    }
199
200    /// Render a pre-compiled template.
201    pub fn render_template(
202        &self,
203        template: &Template,
204        context: HashMap<String, Value>,
205    ) -> Result<String> {
206        let mut renderer = Renderer::new(self, context);
207        renderer.render_template(template)
208    }
209}
210
211// ─── Renderer ─────────────────────────────────────────────────────────────────
212
213struct Renderer<'e> {
214    engine: &'e Engine,
215    /// Scope stack: innermost scope is last.
216    scopes: Vec<HashMap<String, Value>>,
217    /// Hoisted snippet definitions (name → AST node).
218    snippets: HashMap<String, SnippetBlock>,
219}
220
221impl<'e> Renderer<'e> {
222    fn new(engine: &'e Engine, root_context: HashMap<String, Value>) -> Self {
223        Renderer {
224            engine,
225            scopes: vec![root_context],
226            snippets: HashMap::new(),
227        }
228    }
229
230    // ── Variable lookup ───────────────────────────────────────────────────
231
232    fn lookup(&self, name: &str) -> Option<&Value> {
233        for scope in self.scopes.iter().rev() {
234            if let Some(v) = scope.get(name) {
235                return Some(v);
236            }
237        }
238        None
239    }
240
241    fn lookup_value(&self, name: &str) -> Result<Value> {
242        match self.lookup(name) {
243            Some(v) => Ok(v.clone()),
244            None if self.engine.strict => Err(Error::RenderError {
245                message: format!("Undefined variable '{}'", name),
246            }),
247            None => Ok(Value::Null),
248        }
249    }
250
251    fn push_scope(&mut self, scope: HashMap<String, Value>) {
252        self.scopes.push(scope);
253    }
254
255    fn pop_scope(&mut self) {
256        self.scopes.pop();
257    }
258
259    // ── Template rendering ────────────────────────────────────────────────
260
261    fn render_template(&mut self, template: &Template) -> Result<String> {
262        // Hoist snippet definitions.
263        self.hoist_snippets(&template.nodes);
264        self.render_nodes(&template.nodes)
265    }
266
267    fn hoist_snippets(&mut self, nodes: &[Node]) {
268        for node in nodes {
269            if let Node::SnippetBlock(s) = node {
270                self.snippets.insert(s.name.clone(), s.clone());
271            }
272        }
273    }
274
275    fn render_nodes(&mut self, nodes: &[Node]) -> Result<String> {
276        let mut out = String::new();
277        for node in nodes {
278            out.push_str(&self.render_node(node)?);
279        }
280        Ok(out)
281    }
282
283    fn render_node(&mut self, node: &Node) -> Result<String> {
284        match node {
285            Node::RawText(t) => Ok(t.clone()),
286            Node::Comment(_) => Ok(String::new()),
287            Node::ExprTag(t) => {
288                let val = self.eval_expr(&t.expr)?;
289                Ok(val.html_escaped())
290            }
291            Node::HtmlTag(t) => {
292                let val = self.eval_expr(&t.expr)?;
293                Ok(val.to_display_string())
294            }
295            Node::IfBlock(b) => self.render_if(b),
296            Node::EachBlock(b) => self.render_each(b),
297            Node::SnippetBlock(_) => Ok(String::new()), // snippets render only when @render'd
298            Node::RawBlock(content) => Ok(content.clone()),
299            Node::RenderTag(t) => self.render_render_tag(t),
300            Node::ConstTag(t) => {
301                let val = self.eval_expr(&t.expr)?;
302                // Insert into the innermost scope.
303                self.scopes.last_mut().unwrap().insert(t.name.clone(), val);
304                Ok(String::new())
305            }
306            Node::IncludeTag(t) => self.render_include(t),
307            Node::DebugTag(t) => self.render_debug(t),
308        }
309    }
310
311    fn render_if(&mut self, block: &IfBlock) -> Result<String> {
312        for branch in &block.branches {
313            let cond = self.eval_expr(&branch.condition)?;
314            if cond.is_truthy() {
315                return self.render_nodes(&branch.body);
316            }
317        }
318        if let Some(else_body) = &block.else_body {
319            return self.render_nodes(else_body);
320        }
321        Ok(String::new())
322    }
323
324    fn render_each(&mut self, block: &EachBlock) -> Result<String> {
325        let iterable = self.eval_expr(&block.iterable)?;
326        let items = match iterable {
327            Value::Array(arr) => arr,
328            Value::Null => Vec::new(),
329            other => {
330                return Err(Error::RenderError {
331                    message: format!("{{#each}} expects an array, got {}", other.type_name()),
332                });
333            }
334        };
335
336        if items.is_empty() {
337            if let Some(else_body) = &block.else_body {
338                return self.render_nodes(else_body);
339            }
340            return Ok(String::new());
341        }
342
343        let mut out = String::new();
344        for (i, item) in items.iter().enumerate() {
345            let mut scope = HashMap::new();
346            // Bind the loop variable(s).
347            match &block.pattern {
348                Pattern::Ident(name) => {
349                    scope.insert(name.clone(), item.clone());
350                }
351                Pattern::Destructure(keys) => {
352                    if let Value::Object(map) = item {
353                        for key in keys {
354                            let val = map.get(key).cloned().unwrap_or(Value::Null);
355                            scope.insert(key.clone(), val);
356                        }
357                    } else {
358                        return Err(Error::RenderError {
359                            message: format!(
360                                "Destructuring pattern requires an object, got {}",
361                                item.type_name()
362                            ),
363                        });
364                    }
365                }
366            }
367            if let Some(idx_name) = &block.index_binding {
368                scope.insert(idx_name.clone(), Value::Int(i as i64));
369            }
370            self.push_scope(scope);
371            out.push_str(&self.render_nodes(&block.body)?);
372            self.pop_scope();
373        }
374        Ok(out)
375    }
376
377    fn render_render_tag(&mut self, tag: &RenderTag) -> Result<String> {
378        let snippet = self
379            .snippets
380            .get(&tag.name)
381            .cloned()
382            .ok_or_else(|| Error::RenderError {
383                message: format!("Unknown snippet '{}'", tag.name),
384            })?;
385        if snippet.params.len() != tag.args.len() {
386            return Err(Error::RenderError {
387                message: format!(
388                    "Snippet '{}' expects {} argument(s), got {}",
389                    tag.name,
390                    snippet.params.len(),
391                    tag.args.len()
392                ),
393            });
394        }
395        // Evaluate arguments in the current scope before entering the snippet scope.
396        let mut arg_values = Vec::with_capacity(tag.args.len());
397        for arg in &tag.args {
398            arg_values.push(self.eval_expr(arg)?);
399        }
400        let mut scope = HashMap::new();
401        for (name, val) in snippet.params.iter().zip(arg_values) {
402            scope.insert(name.clone(), val);
403        }
404        self.push_scope(scope);
405        let result = self.render_nodes(&snippet.body.clone());
406        self.pop_scope();
407        result
408    }
409
410    fn render_include(&mut self, tag: &IncludeTag) -> Result<String> {
411        let source = self.engine.resolve_template(&tag.path)?;
412        // Collect the current top-scope context snapshot for the included template.
413        let ctx: HashMap<String, Value> = self
414            .scopes
415            .iter()
416            .flat_map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())))
417            .collect();
418        self.engine.render(&source, ctx)
419    }
420
421    fn render_debug(&self, tag: &DebugTag) -> Result<String> {
422        // In a library context we emit nothing; the intent is dev tooling.
423        // A host application can replace this behaviour by post-processing the AST.
424        let _ = tag;
425        Ok(String::new())
426    }
427
428    // ── Expression evaluation ─────────────────────────────────────────────
429
430    fn eval_expr(&mut self, expr: &Expr) -> Result<Value> {
431        match expr {
432            Expr::Null => Ok(Value::Null),
433            Expr::Bool(b) => Ok(Value::Bool(*b)),
434            Expr::Int(i) => Ok(Value::Int(*i)),
435            Expr::Float(f) => Ok(Value::Float(*f)),
436            Expr::String(s) => Ok(Value::String(s.clone())),
437            Expr::Array(elements) => {
438                let mut arr = Vec::with_capacity(elements.len());
439                for e in elements {
440                    arr.push(self.eval_expr(e)?);
441                }
442                Ok(Value::Array(arr))
443            }
444            Expr::Ident(name) => self.lookup_value(name),
445            Expr::MemberAccess { object, property } => {
446                let obj = self.eval_expr(object)?;
447                self.get_property(&obj, property)
448            }
449            Expr::IndexAccess { object, index } => {
450                let obj = self.eval_expr(object)?;
451                let idx = self.eval_expr(index)?;
452                self.get_index(&obj, &idx)
453            }
454            Expr::Filter { expr, filters } => {
455                let mut val = self.eval_expr(expr)?;
456                for f in filters {
457                    let mut arg_vals = Vec::with_capacity(f.args.len());
458                    for a in &f.args {
459                        arg_vals.push(self.eval_expr(a)?);
460                    }
461                    val = if let Some(custom) = self.engine.filters.get(f.name.as_str()) {
462                        custom(val, arg_vals)?
463                    } else {
464                        apply_filter(val, &f.name, arg_vals)?
465                    };
466                }
467                Ok(val)
468            }
469            Expr::Ternary {
470                condition,
471                consequent,
472                alternate,
473            } => {
474                let cond = self.eval_expr(condition)?;
475                if cond.is_truthy() {
476                    self.eval_expr(consequent)
477                } else {
478                    self.eval_expr(alternate)
479                }
480            }
481            Expr::Binary { op, left, right } => self.eval_binary(op, left, right),
482            Expr::Unary { op, operand } => self.eval_unary(op, operand),
483            Expr::Test {
484                expr,
485                negated,
486                test_name,
487            } => {
488                let val = self.eval_expr(expr)?;
489                let result = eval_test(&val, test_name, self)?;
490                Ok(Value::Bool(if *negated { !result } else { result }))
491            }
492            Expr::Membership {
493                expr,
494                negated,
495                collection,
496            } => {
497                let val = self.eval_expr(expr)?;
498                let coll = self.eval_expr(collection)?;
499                let member = eval_membership(&val, &coll)?;
500                Ok(Value::Bool(if *negated { !member } else { member }))
501            }
502        }
503    }
504
505    fn get_property(&self, obj: &Value, prop: &str) -> Result<Value> {
506        match obj {
507            Value::Object(map) => Ok(map.get(prop).cloned().unwrap_or(Value::Null)).and_then(|v| {
508                if self.engine.strict
509                    && !obj.is_null()
510                    && let Value::Object(map) = obj
511                    && !map.contains_key(prop)
512                {
513                    return Err(Error::RenderError {
514                        message: format!("Property '{}' not found on object", prop),
515                    });
516                }
517                Ok(v)
518            }),
519            Value::Null => {
520                if self.engine.strict {
521                    Err(Error::RenderError {
522                        message: format!("Cannot access property '{}' on null", prop),
523                    })
524                } else {
525                    Ok(Value::Null)
526                }
527            }
528            other => {
529                if self.engine.strict {
530                    Err(Error::RenderError {
531                        message: format!(
532                            "Cannot access property '{}' on {}",
533                            prop,
534                            other.type_name()
535                        ),
536                    })
537                } else {
538                    Ok(Value::Null)
539                }
540            }
541        }
542    }
543
544    fn get_index(&self, obj: &Value, idx: &Value) -> Result<Value> {
545        match obj {
546            Value::Array(arr) => {
547                let i = match idx {
548                    Value::Int(i) => *i,
549                    other => {
550                        return Err(Error::RenderError {
551                            message: format!(
552                                "Array index must be an integer, got {}",
553                                other.type_name()
554                            ),
555                        });
556                    }
557                };
558                let len = arr.len() as i64;
559                let i = if i < 0 { len + i } else { i };
560                if i < 0 || i >= len {
561                    if self.engine.strict {
562                        Err(Error::RenderError {
563                            message: format!("Array index {} out of bounds (len {})", i, len),
564                        })
565                    } else {
566                        Ok(Value::Null)
567                    }
568                } else {
569                    Ok(arr[i as usize].clone())
570                }
571            }
572            Value::Object(map) => {
573                let key = match idx {
574                    Value::String(s) => s.clone(),
575                    other => other.to_display_string(),
576                };
577                Ok(map.get(&key).cloned().unwrap_or(Value::Null))
578            }
579            Value::Null => {
580                if self.engine.strict {
581                    Err(Error::RenderError {
582                        message: "Cannot index into null".to_string(),
583                    })
584                } else {
585                    Ok(Value::Null)
586                }
587            }
588            other => Err(Error::RenderError {
589                message: format!("Cannot index into {}", other.type_name()),
590            }),
591        }
592    }
593
594    fn eval_binary(&mut self, op: &BinaryOp, left: &Expr, right: &Expr) -> Result<Value> {
595        // Short-circuit operators evaluated first.
596        match op {
597            BinaryOp::Or => {
598                let l = self.eval_expr(left)?;
599                if l.is_truthy() {
600                    return Ok(l);
601                }
602                return self.eval_expr(right);
603            }
604            BinaryOp::And => {
605                let l = self.eval_expr(left)?;
606                if !l.is_truthy() {
607                    return Ok(l);
608                }
609                return self.eval_expr(right);
610            }
611            BinaryOp::NullCoalesce => {
612                let l = self.eval_expr(left)?;
613                if !l.is_null() {
614                    return Ok(l);
615                }
616                return self.eval_expr(right);
617            }
618            _ => {}
619        }
620
621        let l = self.eval_expr(left)?;
622        let r = self.eval_expr(right)?;
623
624        match op {
625            BinaryOp::Eq => Ok(Value::Bool(values_equal(&l, &r))),
626            BinaryOp::Neq => Ok(Value::Bool(!values_equal(&l, &r))),
627            BinaryOp::Lt => {
628                compare_values(&l, &r).map(|o| Value::Bool(o == std::cmp::Ordering::Less))
629            }
630            BinaryOp::Gt => {
631                compare_values(&l, &r).map(|o| Value::Bool(o == std::cmp::Ordering::Greater))
632            }
633            BinaryOp::Lte => {
634                compare_values(&l, &r).map(|o| Value::Bool(o != std::cmp::Ordering::Greater))
635            }
636            BinaryOp::Gte => {
637                compare_values(&l, &r).map(|o| Value::Bool(o != std::cmp::Ordering::Less))
638            }
639            BinaryOp::Add => match (&l, &r) {
640                // At least one operand is a string → string concatenation.
641                (Value::String(_), _) | (_, Value::String(_)) => Ok(Value::String(
642                    l.to_display_string() + &r.to_display_string(),
643                )),
644                // Both are numeric → arithmetic addition.
645                _ => numeric_op(&l, &r, |a, b| a + b, |a, b| a + b),
646            },
647            BinaryOp::Sub => numeric_op(&l, &r, |a, b| a - b, |a, b| a - b),
648            BinaryOp::Mul => numeric_op(&l, &r, |a, b| a * b, |a, b| a * b),
649            BinaryOp::Div => {
650                let is_zero =
651                    matches!(&r, Value::Int(0)) || matches!(&r, Value::Float(f) if *f == 0.0);
652                if is_zero {
653                    Err(Error::RenderError {
654                        message: "Division by zero".to_string(),
655                    })
656                } else {
657                    numeric_op(&l, &r, |a, b| a / b, |a, b| a / b)
658                }
659            }
660            BinaryOp::Mod => numeric_op(&l, &r, |a, b| a % b, |a, b| a % b),
661            BinaryOp::Or | BinaryOp::And | BinaryOp::NullCoalesce => unreachable!(),
662        }
663    }
664
665    fn eval_unary(&mut self, op: &UnaryOp, operand: &Expr) -> Result<Value> {
666        let val = self.eval_expr(operand)?;
667        match op {
668            UnaryOp::Not => Ok(Value::Bool(!val.is_truthy())),
669            UnaryOp::Neg => match val {
670                Value::Int(i) => Ok(Value::Int(-i)),
671                Value::Float(f) => Ok(Value::Float(-f)),
672                other => Err(Error::RenderError {
673                    message: format!("Cannot negate {}", other.type_name()),
674                }),
675            },
676        }
677    }
678}
679
680// ─── Tests ────────────────────────────────────────────────────────────────────
681
682fn eval_test(val: &Value, test_name: &str, renderer: &Renderer) -> Result<bool> {
683    match test_name {
684        "defined" => Ok(!matches!(val, Value::Null)),
685        "undefined" => Ok(matches!(val, Value::Null)),
686        "none" => Ok(matches!(val, Value::Null)),
687        "odd" => match val {
688            Value::Int(i) => Ok(i % 2 != 0),
689            other => Err(Error::RenderError {
690                message: format!("Test 'odd' requires a number, got {}", other.type_name()),
691            }),
692        },
693        "even" => match val {
694            Value::Int(i) => Ok(i % 2 == 0),
695            other => Err(Error::RenderError {
696                message: format!("Test 'even' requires a number, got {}", other.type_name()),
697            }),
698        },
699        "empty" => Ok(val.is_empty()),
700        "truthy" => Ok(val.is_truthy()),
701        "falsy" => Ok(!val.is_truthy()),
702        "string" => Ok(matches!(val, Value::String(_))),
703        "number" => Ok(matches!(val, Value::Int(_) | Value::Float(_))),
704        "iterable" => Ok(matches!(val, Value::Array(_))),
705        unknown => {
706            if renderer.engine.strict {
707                Err(Error::RenderError {
708                    message: format!("Unknown test '{}'", unknown),
709                })
710            } else {
711                Ok(false)
712            }
713        }
714    }
715}
716
717// ─── Membership ───────────────────────────────────────────────────────────────
718
719fn eval_membership(val: &Value, collection: &Value) -> Result<bool> {
720    match collection {
721        Value::Array(arr) => Ok(arr.contains(val)),
722        Value::Object(map) => {
723            let key = val.to_display_string();
724            Ok(map.contains_key(&key))
725        }
726        Value::String(haystack) => {
727            let needle = val.to_display_string();
728            Ok(haystack.contains(&needle[..]))
729        }
730        other => Err(Error::RenderError {
731            message: format!(
732                "'in' operator requires an array, object, or string, got {}",
733                other.type_name()
734            ),
735        }),
736    }
737}
738
739// ─── Comparisons ─────────────────────────────────────────────────────────────
740
741fn values_equal(a: &Value, b: &Value) -> bool {
742    match (a, b) {
743        (Value::Null, Value::Null) => true,
744        (Value::Bool(x), Value::Bool(y)) => x == y,
745        (Value::Int(x), Value::Int(y)) => x == y,
746        (Value::Float(x), Value::Float(y)) => x == y,
747        (Value::Int(x), Value::Float(y)) => (*x as f64) == *y,
748        (Value::Float(x), Value::Int(y)) => *x == (*y as f64),
749        (Value::String(x), Value::String(y)) => x == y,
750        _ => false,
751    }
752}
753
754fn compare_values(a: &Value, b: &Value) -> Result<std::cmp::Ordering> {
755    match (a, b) {
756        (Value::Int(x), Value::Int(y)) => Ok(x.cmp(y)),
757        (Value::Float(x), Value::Float(y)) => x.partial_cmp(y).ok_or(Error::RenderError {
758            message: "Cannot compare NaN".to_string(),
759        }),
760        (Value::Int(x), Value::Float(y)) => (*x as f64).partial_cmp(y).ok_or(Error::RenderError {
761            message: "Cannot compare NaN".to_string(),
762        }),
763        (Value::Float(x), Value::Int(y)) => x.partial_cmp(&(*y as f64)).ok_or(Error::RenderError {
764            message: "Cannot compare NaN".to_string(),
765        }),
766        (Value::String(x), Value::String(y)) => Ok(x.cmp(y)),
767        _ => Err(Error::RenderError {
768            message: format!("Cannot compare {} and {}", a.type_name(), b.type_name()),
769        }),
770    }
771}
772
773fn numeric_op(
774    l: &Value,
775    r: &Value,
776    int_op: impl Fn(i64, i64) -> i64,
777    float_op: impl Fn(f64, f64) -> f64,
778) -> Result<Value> {
779    match (l, r) {
780        (Value::Int(a), Value::Int(b)) => Ok(Value::Int(int_op(*a, *b))),
781        (Value::Float(a), Value::Float(b)) => Ok(Value::Float(float_op(*a, *b))),
782        (Value::Int(a), Value::Float(b)) => Ok(Value::Float(float_op(*a as f64, *b))),
783        (Value::Float(a), Value::Int(b)) => Ok(Value::Float(float_op(*a, *b as f64))),
784        _ => Err(Error::RenderError {
785            message: format!(
786                "Arithmetic requires numbers, got {} and {}",
787                l.type_name(),
788                r.type_name()
789            ),
790        }),
791    }
792}
793
794// ─── Built-in filters ─────────────────────────────────────────────────────────
795
796fn apply_filter(val: Value, name: &str, args: Vec<Value>) -> Result<Value> {
797    match name {
798        // ── String filters ────────────────────────────────────────────────
799        "upper" => {
800            let s = require_string(&val, "upper")?;
801            Ok(Value::String(s.to_uppercase()))
802        }
803        "lower" => {
804            let s = require_string(&val, "lower")?;
805            Ok(Value::String(s.to_lowercase()))
806        }
807        "capitalize" => {
808            let s = require_string(&val, "capitalize")?;
809            let mut chars = s.chars();
810            let out = match chars.next() {
811                None => String::new(),
812                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
813            };
814            Ok(Value::String(out))
815        }
816        "trim" => {
817            let s = require_string(&val, "trim")?;
818            Ok(Value::String(s.trim().to_string()))
819        }
820        "truncate" => {
821            let s = require_string(&val, "truncate")?;
822            let len = require_int_arg(&args, 0, "truncate")? as usize;
823            if s.chars().count() <= len {
824                Ok(Value::String(s.to_string()))
825            } else {
826                let truncated: String = s.chars().take(len.saturating_sub(3)).collect();
827                Ok(Value::String(truncated + "..."))
828            }
829        }
830        "replace" => {
831            let s = require_string(&val, "replace")?;
832            let from = require_string_arg(&args, 0, "replace")?;
833            let to = require_string_arg(&args, 1, "replace")?;
834            Ok(Value::String(s.replace(&from[..], &to[..])))
835        }
836        "split" => {
837            let s = require_string(&val, "split")?;
838            let sep = require_string_arg(&args, 0, "split")?;
839            let parts: Vec<Value> = s
840                .split(&sep[..])
841                .map(|p| Value::String(p.to_string()))
842                .collect();
843            Ok(Value::Array(parts))
844        }
845
846        // ── Collection filters ────────────────────────────────────────────
847        "sort" => {
848            let mut arr = require_array(val, "sort")?;
849            arr.sort_by(|a, b| compare_values(a, b).unwrap_or(std::cmp::Ordering::Equal));
850            Ok(Value::Array(arr))
851        }
852        "reverse" => match val {
853            Value::Array(mut arr) => {
854                arr.reverse();
855                Ok(Value::Array(arr))
856            }
857            Value::String(s) => Ok(Value::String(s.chars().rev().collect())),
858            other => Err(Error::RenderError {
859                message: format!(
860                    "Filter 'reverse' expects array or string, got {}",
861                    other.type_name()
862                ),
863            }),
864        },
865        "join" => {
866            let arr = require_array(val, "join")?;
867            let sep = if args.is_empty() {
868                String::new()
869            } else {
870                require_string_arg(&args, 0, "join")?.to_string()
871            };
872            let parts: Vec<String> = arr.iter().map(|v| v.to_display_string()).collect();
873            Ok(Value::String(parts.join(&sep)))
874        }
875        "first" => {
876            let arr = require_array(val, "first")?;
877            Ok(arr.into_iter().next().unwrap_or(Value::Null))
878        }
879        "last" => {
880            let arr = require_array(val, "last")?;
881            Ok(arr.into_iter().next_back().unwrap_or(Value::Null))
882        }
883        "length" => {
884            let len = val.length().ok_or_else(|| Error::RenderError {
885                message: format!(
886                    "Filter 'length' expects string, array, or object, got {}",
887                    val.type_name()
888                ),
889            })?;
890            Ok(Value::Int(len as i64))
891        }
892
893        // ── Formatting filters ────────────────────────────────────────────
894        "default" => {
895            if val.is_null() {
896                Ok(args.into_iter().next().unwrap_or(Value::Null))
897            } else {
898                Ok(val)
899            }
900        }
901        "json" => Ok(Value::String(val.to_json_string())),
902        "round" => {
903            let precision = if args.is_empty() {
904                0usize
905            } else {
906                require_int_arg(&args, 0, "round")? as usize
907            };
908            match val {
909                Value::Int(i) => Ok(Value::Int(i)),
910                Value::Float(f) => {
911                    let factor = 10f64.powi(precision as i32);
912                    Ok(Value::Float((f * factor).round() / factor))
913                }
914                other => Err(Error::RenderError {
915                    message: format!("Filter 'round' expects a number, got {}", other.type_name()),
916                }),
917            }
918        }
919
920        // ── Escaping filters ──────────────────────────────────────────────
921        "urlencode" => {
922            let s = require_string(&val, "urlencode")?;
923            Ok(Value::String(urlencode(s)))
924        }
925        "escape" => {
926            let s = val.to_display_string();
927            Ok(Value::String(html_escape(&s)))
928        }
929
930        unknown => Err(Error::RenderError {
931            message: format!("Unknown filter '{}'", unknown),
932        }),
933    }
934}
935
936// ── Filter argument helpers ───────────────────────────────────────────────────
937
938fn require_string<'a>(val: &'a Value, filter: &str) -> Result<&'a str> {
939    match val {
940        Value::String(s) => Ok(s),
941        other => Err(Error::RenderError {
942            message: format!(
943                "Filter '{}' expects a string, got {}",
944                filter,
945                other.type_name()
946            ),
947        }),
948    }
949}
950
951fn require_array(val: Value, filter: &str) -> Result<Vec<Value>> {
952    match val {
953        Value::Array(arr) => Ok(arr),
954        other => Err(Error::RenderError {
955            message: format!(
956                "Filter '{}' expects an array, got {}",
957                filter,
958                other.type_name()
959            ),
960        }),
961    }
962}
963
964fn require_string_arg(args: &[Value], idx: usize, filter: &str) -> Result<String> {
965    args.get(idx)
966        .and_then(|v| {
967            if let Value::String(s) = v {
968                Some(s.clone())
969            } else {
970                None
971            }
972        })
973        .ok_or_else(|| Error::RenderError {
974            message: format!("Filter '{}' argument {} must be a string", filter, idx + 1),
975        })
976}
977
978fn require_int_arg(args: &[Value], idx: usize, filter: &str) -> Result<i64> {
979    match args.get(idx) {
980        Some(Value::Int(i)) => Ok(*i),
981        Some(Value::Float(f)) => Ok(*f as i64),
982        _ => Err(Error::RenderError {
983            message: format!("Filter '{}' argument {} must be a number", filter, idx + 1),
984        }),
985    }
986}