1pub 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#[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 pub fn parse_str(name: &str, source: &str) -> Result<Self, Error> {
53 Self::parse_with_functions(name, source, FunctionRegistry::empty())
54 }
55
56 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 pub fn functions(&self) -> FunctionRegistry {
73 self.functions.clone()
74 }
75
76 pub fn set_functions(&mut self, functions: FunctionRegistry) {
78 self.functions = functions;
79 }
80
81 pub fn with_functions(mut self, functions: FunctionRegistry) -> Self {
83 self.functions = functions;
84 self
85 }
86
87 pub fn name(&self) -> &str {
89 &self.name
90 }
91
92 pub fn source(&self) -> &str {
94 &self.source
95 }
96
97 pub fn ast(&self) -> &Ast {
99 &self.ast
100 }
101
102 pub fn analyze(&self) -> TemplateAnalysis {
104 analyze::analyze_template(&self.ast, Some(&self.functions))
105 }
106
107 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 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 } 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 } 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}