lithos_gotmpl_engine/
lib.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Core utilities for parsing and rendering Go-style text templates in Rust.
3//!
4//! This crate is work-in-progress: it currently provides AST inspection and a
5//! placeholder renderer while the full evaluator is implemented.
6
7pub mod analyze;
8pub mod ast;
9mod error;
10pub mod lexer;
11mod parser;
12mod runtime;
13
14pub use analyze::{
15    analyze_template, AnalysisIssue, Certainty, ControlKind, ControlUsage, FunctionCall,
16    FunctionSource, Precision, TemplateAnalysis, TemplateCall, VariableAccess, VariableKind,
17};
18pub use ast::{
19    ActionNode, Ast, BindingKind, Block, Command, CommentNode, Expression, IfNode, Node, Pipeline,
20    PipelineDeclarations, RangeNode, Span, TextNode, WithNode,
21};
22pub use error::Error;
23pub use lexer::{Keyword, Operator, Token, TokenKind};
24pub use runtime::{
25    coerce_number, is_empty, is_truthy, value_to_string, EvalContext, Function, FunctionRegistry,
26    FunctionRegistryBuilder,
27};
28
29use serde_json::{Number, Value};
30use std::fmt;
31
32/// Parsed template with associated AST and original source.
33#[derive(Clone)]
34pub struct Template {
35    name: String,
36    source: String,
37    ast: Ast,
38    functions: FunctionRegistry,
39}
40
41impl fmt::Debug for Template {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        f.debug_struct("Template")
44            .field("name", &self.name)
45            .field("source", &self.source)
46            .finish()
47    }
48}
49
50impl Template {
51    /// Parses template source into an AST representation.
52    pub fn parse_str(name: &str, source: &str) -> Result<Self, Error> {
53        Self::parse_with_functions(name, source, FunctionRegistry::empty())
54    }
55
56    /// Parses template source and associates it with a registry of functions.
57    pub fn parse_with_functions(
58        name: &str,
59        source: &str,
60        functions: FunctionRegistry,
61    ) -> Result<Self, Error> {
62        let ast = parser::parse_template(name, source)?;
63        Ok(Self {
64            name: name.to_string(),
65            source: source.to_string(),
66            ast,
67            functions,
68        })
69    }
70
71    /// Returns a clone of the function registry in use.
72    pub fn functions(&self) -> FunctionRegistry {
73        self.functions.clone()
74    }
75
76    /// Replaces the function registry associated with this template.
77    pub fn set_functions(&mut self, functions: FunctionRegistry) {
78        self.functions = functions;
79    }
80
81    /// Consumes the template and returns a new instance with the provided function registry.
82    pub fn with_functions(mut self, functions: FunctionRegistry) -> Self {
83        self.functions = functions;
84        self
85    }
86
87    /// Returns the original template name.
88    pub fn name(&self) -> &str {
89        &self.name
90    }
91
92    /// Returns the original template source.
93    pub fn source(&self) -> &str {
94        &self.source
95    }
96
97    /// Returns a reference to the parsed AST.
98    pub fn ast(&self) -> &Ast {
99        &self.ast
100    }
101
102    /// Runs structural analysis over the template and returns helper usage metadata.
103    pub fn analyze(&self) -> TemplateAnalysis {
104        analyze::analyze_template(&self.ast, Some(&self.functions))
105    }
106
107    /// Returns a canonical string representation of the parsed template, similar to Go's
108    /// `parse.Tree.Root.String()` output.
109    pub fn to_template_string(&self) -> String {
110        let mut out = String::new();
111        Self::write_block(&mut out, &self.ast.root);
112        out
113    }
114
115    fn write_block(out: &mut String, block: &Block) {
116        for node in &block.nodes {
117            match node {
118                Node::Text(text) => out.push_str(&text.text),
119                Node::Comment(comment) => out.push_str(&comment.to_template_fragment()),
120                Node::Action(action) => out.push_str(&action.to_template_fragment()),
121                Node::If(if_node) => {
122                    out.push_str("{{if ");
123                    out.push_str(&pipeline_to_string(&if_node.pipeline));
124                    out.push_str("}}");
125                    Self::write_block(out, &if_node.then_block);
126                    if let Some(else_block) = &if_node.else_block {
127                        out.push_str("{{else}}");
128                        Self::write_block(out, else_block);
129                    }
130                    out.push_str("{{end}}");
131                }
132                Node::Range(range_node) => {
133                    out.push_str("{{range ");
134                    out.push_str(&pipeline_to_string(&range_node.pipeline));
135                    out.push_str("}}");
136                    Self::write_block(out, &range_node.then_block);
137                    if let Some(else_block) = &range_node.else_block {
138                        out.push_str("{{else}}");
139                        Self::write_block(out, else_block);
140                    }
141                    out.push_str("{{end}}");
142                }
143                Node::With(with_node) => {
144                    out.push_str("{{with ");
145                    out.push_str(&pipeline_to_string(&with_node.pipeline));
146                    out.push_str("}}");
147                    Self::write_block(out, &with_node.then_block);
148                    if let Some(else_block) = &with_node.else_block {
149                        out.push_str("{{else}}");
150                        Self::write_block(out, else_block);
151                    }
152                    out.push_str("{{end}}");
153                }
154            }
155        }
156    }
157
158    /// Renders the template against the provided data.
159    pub fn render(&self, data: &Value) -> Result<String, Error> {
160        let mut ctx = runtime::EvalContext::new(data.clone(), self.functions.clone());
161        let mut output = String::new();
162        Self::render_block(&mut ctx, &self.ast.root, &mut output)?;
163        Ok(output)
164    }
165
166    fn render_block(
167        ctx: &mut runtime::EvalContext,
168        block: &Block,
169        output: &mut String,
170    ) -> Result<(), Error> {
171        for node in &block.nodes {
172            match node {
173                Node::Text(text) => output.push_str(&text.text),
174                Node::Comment(_) => {}
175                Node::Action(action) => {
176                    let value = ctx.eval_pipeline(&action.pipeline)?;
177                    ctx.apply_bindings(&action.pipeline, &value)?;
178                    if action.pipeline.declarations.is_none() {
179                        output.push_str(&runtime::value_to_string(&value));
180                    }
181                }
182                Node::If(if_node) => Self::render_if(ctx, if_node, output)?,
183                Node::Range(range_node) => Self::render_range(ctx, range_node, output)?,
184                Node::With(with_node) => Self::render_with(ctx, with_node, output)?,
185            }
186        }
187        Ok(())
188    }
189
190    fn render_if(
191        ctx: &mut runtime::EvalContext,
192        node: &crate::ast::IfNode,
193        output: &mut String,
194    ) -> Result<(), Error> {
195        let value = ctx.eval_pipeline(&node.pipeline)?;
196        ctx.apply_bindings(&node.pipeline, &value)?;
197        if runtime::is_truthy(&value) {
198            Self::render_block(ctx, &node.then_block, output)?;
199        } else if let Some(else_block) = &node.else_block {
200            Self::render_block(ctx, else_block, output)?;
201        }
202        Ok(())
203    }
204
205    fn render_range(
206        ctx: &mut runtime::EvalContext,
207        node: &crate::ast::RangeNode,
208        output: &mut String,
209    ) -> Result<(), Error> {
210        ctx.predeclare_bindings(&node.pipeline);
211        let value = ctx.eval_pipeline(&node.pipeline)?;
212
213        let mut iterated = false;
214
215        match value {
216            Value::Array(items) => {
217                if items.is_empty() {
218                    // handled later for else
219                } else {
220                    for (index, item) in items.iter().enumerate() {
221                        let key_value = Value::Number(Number::from(index as u64));
222                        ctx.assign_range_bindings(&node.pipeline, Some(key_value), item.clone())?;
223                        ctx.push_scope(item.clone());
224                        let render_result = Self::render_block(ctx, &node.then_block, output);
225                        ctx.pop_scope();
226                        render_result?;
227                        iterated = true;
228                    }
229                }
230            }
231            Value::Object(map) => {
232                if map.is_empty() {
233                    // handled later
234                } else {
235                    for (key, val) in map.iter() {
236                        let key_value = Value::String(key.clone());
237                        ctx.assign_range_bindings(&node.pipeline, Some(key_value), val.clone())?;
238                        ctx.push_scope(val.clone());
239                        let render_result = Self::render_block(ctx, &node.then_block, output);
240                        ctx.pop_scope();
241                        render_result?;
242                        iterated = true;
243                    }
244                }
245            }
246            _ => {}
247        }
248
249        if !iterated {
250            ctx.assign_range_bindings(&node.pipeline, None, Value::Null)?;
251            if let Some(else_block) = &node.else_block {
252                Self::render_block(ctx, else_block, output)?;
253            }
254        }
255
256        Ok(())
257    }
258
259    fn render_with(
260        ctx: &mut runtime::EvalContext,
261        node: &crate::ast::WithNode,
262        output: &mut String,
263    ) -> Result<(), Error> {
264        let value = ctx.eval_pipeline(&node.pipeline)?;
265        ctx.apply_bindings(&node.pipeline, &value)?;
266        if runtime::is_truthy(&value) {
267            ctx.push_scope(value.clone());
268            let render_result = Self::render_block(ctx, &node.then_block, output);
269            ctx.pop_scope();
270            render_result?;
271        } else if let Some(else_block) = &node.else_block {
272            Self::render_block(ctx, else_block, output)?;
273        }
274        Ok(())
275    }
276}
277
278fn pipeline_to_string(pipeline: &Pipeline) -> String {
279    let mut out = String::new();
280    if let Some(decls) = &pipeline.declarations {
281        out.push_str(&decls.variables.join(", "));
282        out.push(' ');
283        out.push_str(match decls.kind {
284            BindingKind::Declare => ":=",
285            BindingKind::Assign => "=",
286        });
287        out.push(' ');
288    }
289
290    for (idx, command) in pipeline.commands.iter().enumerate() {
291        if idx > 0 {
292            out.push_str(" | ");
293        }
294        out.push_str(&expression_to_string(&command.target));
295        for arg in &command.args {
296            out.push(' ');
297            out.push_str(&expression_to_string(arg));
298        }
299    }
300
301    out
302}
303
304fn expression_to_string(expr: &Expression) -> String {
305    match expr {
306        Expression::Identifier(name) => name.clone(),
307        Expression::Field(parts) => {
308            if parts.is_empty() {
309                ".".to_string()
310            } else {
311                format!(".{}", parts.join("."))
312            }
313        }
314        Expression::Variable(name) => name.clone(),
315        Expression::PipelineExpr(pipeline) => {
316            format!("({})", pipeline_to_string(pipeline))
317        }
318        Expression::StringLiteral(value) => format!("\"{}\"", value),
319        Expression::NumberLiteral(value) => value.clone(),
320        Expression::BoolLiteral(flag) => flag.to_string(),
321        Expression::Nil => "nil".to_string(),
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde_json::{json, Value};
329
330    #[test]
331    fn renders_with_custom_registry() {
332        let mut builder = FunctionRegistry::builder();
333        builder.register("greet", |_ctx, args| {
334            let name = args
335                .first()
336                .cloned()
337                .unwrap_or_else(|| Value::String("friend".into()));
338            Ok(Value::String(format!("Hello, {}!", value_to_string(&name))))
339        });
340        let registry = builder.build();
341
342        let tmpl = Template::parse_with_functions("test", "{{greet .name}}", registry).unwrap();
343        let rendered = tmpl.render(&json!({"name": "Hans"})).unwrap();
344        assert_eq!(rendered, "Hello, Hans!");
345    }
346
347    #[test]
348    fn missing_function_is_error() {
349        let tmpl = Template::parse_str("missing", "{{unknown .}} ").unwrap();
350        let err = tmpl.render(&json!(1)).unwrap_err();
351        assert!(err.to_string().contains("unknown function"));
352    }
353
354    #[test]
355    fn parse_error_on_unclosed_action() {
356        let err = Template::parse_str("bad", "{{ \"d\" }").unwrap_err();
357        assert!(matches!(err, Error::Parse { .. }));
358        assert!(err.to_string().contains("unclosed action"));
359    }
360
361    #[test]
362    fn raw_string_literal_roundtrip() {
363        let tmpl = Template::parse_str("raw", "{{ `{{ \"d\" }` }}").unwrap();
364        let output = tmpl.render(&json!({})).unwrap();
365        assert_eq!(output, "{{ \"d\" }");
366    }
367
368    #[test]
369    fn renders_if_else_branches() {
370        let tmpl = Template::parse_str("if", "{{if .flag}}yes{{else}}no{{end}}").unwrap();
371        let rendered_true = tmpl.render(&json!({"flag": true})).unwrap();
372        let rendered_false = tmpl.render(&json!({"flag": false})).unwrap();
373        assert_eq!(rendered_true, "yes");
374        assert_eq!(rendered_false, "no");
375    }
376
377    #[test]
378    fn renders_range_over_arrays() {
379        let tmpl =
380            Template::parse_str("range", "{{range .items}}{{.}},{{else}}empty{{end}}").unwrap();
381        let rendered = tmpl.render(&json!({"items": ["a", "b"]})).unwrap();
382        assert_eq!(rendered, "a,b,");
383
384        let empty = tmpl.render(&json!({"items": []})).unwrap();
385        assert_eq!(empty, "empty");
386    }
387
388    #[test]
389    fn renders_with_changes_context() {
390        let tmpl =
391            Template::parse_str("with", "{{with .user}}{{.name}}{{else}}missing{{end}}").unwrap();
392        let rendered = tmpl.render(&json!({"user": {"name": "Lithos"}})).unwrap();
393        assert_eq!(rendered, "Lithos");
394
395        let missing = tmpl.render(&json!({"user": null})).unwrap();
396        assert_eq!(missing, "missing");
397    }
398
399    #[test]
400    fn trims_whitespace_around_actions() {
401        let tmpl = Template::parse_str("trim", "Line1\n{{- \"Line2\" -}}\nLine3").unwrap();
402        let output = tmpl.render(&json!({})).unwrap();
403        assert_eq!(output, "Line1Line2Line3");
404    }
405
406    #[test]
407    fn variable_binding_inside_if() {
408        let tmpl = Template::parse_str("if-var", "{{if $val := .value}}{{$val}}{{end}}").unwrap();
409        let output = tmpl.render(&json!({"value": "ok"})).unwrap();
410        assert_eq!(output, "ok");
411    }
412
413    #[test]
414    fn range_assigns_iteration_variables() {
415        let tmpl = Template::parse_str(
416            "range-vars",
417            "{{range $i, $v := .items}}{{$i}}:{{$v}};{{end}}",
418        )
419        .unwrap();
420        let output = tmpl.render(&json!({"items": ["zero", "one"]})).unwrap();
421        assert_eq!(output, "0:zero;1:one;");
422    }
423
424    #[test]
425    fn comment_trimming_matches_go() {
426        let left = Template::parse_str("comment-left", "x \r\n\t{{- /* hi */}}").unwrap();
427        assert_eq!(left.render(&json!({})).unwrap(), "x");
428        assert_eq!(left.to_template_string(), "x{{-/*hi*/}}");
429
430        let right = Template::parse_str("comment-right", "{{/* hi */ -}}\n\n\ty").unwrap();
431        assert_eq!(right.render(&json!({})).unwrap(), "y");
432        assert_eq!(right.to_template_string(), "{{/*hi*/-}}y");
433
434        let both =
435            Template::parse_str("comment-both", "left \n{{- /* trim */ -}}\n right").unwrap();
436        assert_eq!(both.render(&json!({})).unwrap(), "leftright");
437        assert_eq!(both.to_template_string(), "left{{-/*trim*/-}}right");
438    }
439
440    #[test]
441    fn comment_only_renders_empty_string() {
442        let tmpl = Template::parse_str("comment-only", "{{/* comment */}}").unwrap();
443        assert_eq!(tmpl.render(&json!({})).unwrap(), "");
444    }
445
446    #[test]
447    fn root_variable_resolves_to_input() {
448        let tmpl = Template::parse_str("root", "{{ $.name }}").unwrap();
449        let rendered = tmpl.render(&json!({"name": "Lithos"})).unwrap();
450        assert_eq!(rendered.trim(), "Lithos");
451    }
452
453    #[test]
454    fn nested_scope_shadowing_preserves_outer() {
455        let tmpl = Template::parse_str(
456            "shadow",
457            "{{ $x := \"outer\" }}{{ with .inner }}{{ $x := \"inner\" }}{{ $x }}{{ end }}{{ $x }}",
458        )
459        .unwrap();
460        let rendered: String = tmpl
461            .render(&json!({"inner": {"value": 1}}))
462            .unwrap()
463            .chars()
464            .filter(|c| !c.is_whitespace())
465            .collect();
466        assert_eq!(rendered, "innerouter");
467    }
468
469    #[test]
470    fn assignment_updates_existing_variable() {
471        let tmpl = Template::parse_str(
472            "assign",
473            "{{ $v := \"first\" }}{{ $v = \"second\" }}{{ $v }}",
474        )
475        .unwrap();
476        let rendered = tmpl.render(&json!({})).unwrap();
477        assert_eq!(rendered, "second");
478    }
479
480    #[test]
481    fn assignment_to_unknown_variable_fails() {
482        let tmpl = Template::parse_str("assign", "{{ $v = .value }}")
483            .expect("assignment pipeline should parse");
484        let err = tmpl.render(&json!({"value": 1})).unwrap_err();
485        assert!(err.to_string().contains("variable $v not defined"));
486    }
487
488    #[test]
489    fn pipeline_expression_inside_if() {
490        let mut builder = FunctionRegistry::builder();
491        builder
492            .register("default", |_ctx, args| {
493                let fallback = args.first().cloned().unwrap_or(Value::Null);
494                let value = args.get(1).cloned().unwrap_or(Value::Null);
495                if is_empty(&value) {
496                    Ok(fallback)
497                } else {
498                    Ok(value)
499                }
500            })
501            .register("ge", |_ctx, args| {
502                if args.len() != 2 {
503                    return Err(Error::render("ge expects two arguments", None));
504                }
505                let left = coerce_number(&args[0])?;
506                let right = coerce_number(&args[1])?;
507                Ok(Value::Bool(left >= right))
508            });
509        let registry = builder.build();
510
511        let tmpl = Template::parse_with_functions(
512            "pipeline-if",
513            "# {{ if ge (.x | default 1) 1 }}\nyes \n# {{ end }}",
514            registry,
515        )
516        .unwrap();
517
518        let rendered = tmpl.render(&json!({})).unwrap();
519        assert_eq!(rendered, "# \nyes \n# ");
520        assert!(tmpl
521            .to_template_string()
522            .contains("{{if ge (.x | default 1) 1}}"));
523    }
524}