1use std::sync::Mutex;
4
5use handlebars::{
6 BlockContext, Context, Handlebars, Helper, HelperDef, Output, RenderContext, RenderError,
7 RenderErrorReason, Renderable, ScopedJson, StringOutput,
8};
9use serde_json::Value as Json;
10
11#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
13fn to_i64(value: f64) -> Option<i64> {
14 const MAX_ACCURATE_VALUE: f64 = (1_i64 << 53) as f64;
15 const MIN_ACCURATE_VALUE: f64 = -(1_i64 << 53) as f64;
16
17 if (MIN_ACCURATE_VALUE..=MAX_ACCURATE_VALUE).contains(&value) {
18 Some(value as i64)
19 } else {
20 None
21 }
22}
23
24#[derive(Debug)]
25struct ScopeHelper;
26
27impl HelperDef for ScopeHelper {
28 #[cfg_attr(
29 feature = "tracing",
30 tracing::instrument(level = "trace", skip_all, err, fields(helper.hash = ?helper.hash()))
31 )]
32 fn call<'reg: 'rc, 'rc>(
33 &self,
34 helper: &Helper<'rc>,
35 reg: &'reg Handlebars<'reg>,
36 ctx: &'rc Context,
37 render_ctx: &mut RenderContext<'reg, 'rc>,
38 out: &mut dyn Output,
39 ) -> Result<(), RenderError> {
40 const MESSAGE: &str = "`scope` must be called as block helper";
41
42 let template = helper
43 .template()
44 .ok_or(RenderErrorReason::BlockContentRequired)?;
45 if !helper.params().is_empty() {
46 return Err(RenderErrorReason::Other(MESSAGE.to_owned()).into());
47 }
48
49 for (name, value) in helper.hash() {
50 let helper = VarHelper::new(value.value().clone());
51 render_ctx.register_local_helper(name, Box::new(helper));
52 }
53
54 let result = template.render(reg, ctx, render_ctx, out);
55 for name in helper.hash().keys() {
56 render_ctx.unregister_local_helper(name);
57 }
58 result
59 }
60}
61
62#[derive(Debug)]
63struct VarHelper {
64 value: Mutex<Json>,
65}
66
67impl VarHelper {
68 fn new(value: Json) -> Self {
69 Self {
70 value: Mutex::new(value),
71 }
72 }
73
74 fn set_value(&self, value: Json) {
75 #[cfg(feature = "tracing")]
76 tracing::trace!(?value, "overwritten var");
77 *self.value.lock().unwrap() = value;
78 }
79}
80
81impl HelperDef for VarHelper {
82 #[cfg_attr(
83 feature = "tracing",
84 tracing::instrument(
85 level = "trace",
86 skip_all, err,
87 fields(
88 self = ?self,
89 helper.name = helper.name(),
90 helper.is_block = helper.is_block(),
91 helper.set = ?helper.hash_get("set")
92 )
93 )
94 )]
95 fn call_inner<'reg: 'rc, 'rc>(
96 &self,
97 helper: &Helper<'rc>,
98 reg: &'reg Handlebars<'reg>,
99 ctx: &'rc Context,
100 render_ctx: &mut RenderContext<'reg, 'rc>,
101 ) -> Result<ScopedJson<'rc>, RenderError> {
102 if helper.is_block() {
103 if !helper.params().is_empty() {
104 let message = "In block form, var helpers must be called without args";
105 return Err(RenderErrorReason::Other(message.to_owned()).into());
106 }
107
108 let value = if let Some(template) = helper.template() {
109 let mut output = StringOutput::new();
110 template.render(reg, ctx, render_ctx, &mut output)?;
111 let json_string = output.into_string()?;
112 serde_json::from_str(&json_string).map_err(RenderErrorReason::from)?
113 } else {
114 Json::Null
115 };
116
117 self.set_value(value);
118 Ok(ScopedJson::Constant(&Json::Null))
119 } else {
120 if !helper.params().is_empty() {
121 let message = "variable helper misuse; should be called without args";
122 return Err(RenderErrorReason::Other(message.to_owned()).into());
123 }
124
125 if let Some(value) = helper.hash_get("set") {
126 self.set_value(value.value().clone());
128 Ok(ScopedJson::Constant(&Json::Null))
129 } else {
130 let value = self.value.lock().unwrap().clone();
132 Ok(ScopedJson::Derived(value))
133 }
134 }
135 }
136}
137
138#[derive(Debug)]
139enum OpsHelper {
140 Add,
141 Mul,
142 Sub,
143 Div,
144}
145
146impl OpsHelper {
147 fn as_str(&self) -> &'static str {
148 match self {
149 Self::Add => "add",
150 Self::Mul => "mul",
151 Self::Sub => "sub",
152 Self::Div => "div",
153 }
154 }
155
156 fn accumulate_i64(&self, mut values: impl Iterator<Item = i64>) -> i64 {
157 match self {
158 Self::Add => values.sum(),
159 Self::Mul => values.product(),
160 Self::Sub => values.next().unwrap() - values.next().unwrap(),
162 Self::Div => unreachable!(),
163 }
164 }
165
166 fn accumulate_f64(&self, mut values: impl Iterator<Item = f64>) -> f64 {
167 match self {
168 Self::Add => values.sum(),
169 Self::Mul => values.product(),
170 Self::Sub => values.next().unwrap() - values.next().unwrap(),
172 Self::Div => values.next().unwrap() / values.next().unwrap(),
173 }
174 }
175}
176
177impl HelperDef for OpsHelper {
178 #[cfg_attr(
179 feature = "tracing",
180 tracing::instrument(
181 level = "trace",
182 skip_all, err,
183 fields(
184 self = ?self,
185 helper.name = helper.name(),
186 helper.params = ?helper.params(),
187 helper.round = ?helper.hash_get("round")
188 )
189 )
190 )]
191 fn call_inner<'reg: 'rc, 'rc>(
192 &self,
193 helper: &Helper<'rc>,
194 _: &'reg Handlebars<'reg>,
195 _: &'rc Context,
196 _: &mut RenderContext<'reg, 'rc>,
197 ) -> Result<ScopedJson<'rc>, RenderError> {
198 if matches!(self, Self::Sub | Self::Div) && helper.params().len() != 2 {
199 let message = format!("`{}` expects exactly 2 number args", self.as_str());
200 return Err(RenderErrorReason::Other(message).into());
201 }
202
203 if !matches!(self, Self::Div) {
204 let all_ints = helper.params().iter().all(|param| param.value().is_i64());
205 #[cfg(feature = "tracing")]
206 tracing::trace!(all_ints, "checked if all numbers are ints");
207
208 if all_ints {
209 let values = helper
210 .params()
211 .iter()
212 .map(|param| param.value().as_i64().unwrap());
213 let acc = self.accumulate_i64(values);
214 return Ok(ScopedJson::Derived(acc.into()));
215 }
216 }
217
218 let all_floats = helper
219 .params()
220 .iter()
221 .all(|param| param.value().as_f64().is_some());
222 if all_floats {
223 let values = helper
224 .params()
225 .iter()
226 .map(|param| param.value().as_f64().unwrap());
227 let mut acc = self.accumulate_f64(values);
228 let acc: Json = if let Some(rounding) = helper.hash_get("round") {
229 if matches!(rounding.value(), Json::Bool(true)) {
230 acc = acc.round();
231 } else if rounding.value().as_str() == Some("up") {
232 acc = acc.ceil();
233 } else if rounding.value().as_str() == Some("down") {
234 acc = acc.floor();
235 }
236 to_i64(acc).map_or_else(|| acc.into(), Into::into)
239 } else {
240 acc.into()
241 };
242 Ok(ScopedJson::Derived(acc))
243 } else {
244 let message = "all args must be numbers";
245 Err(RenderErrorReason::Other(message.to_owned()).into())
246 }
247 }
248}
249
250#[derive(Debug)]
251struct EvalHelper;
252
253impl EvalHelper {
254 const NAME: &'static str = "eval";
255}
256
257impl HelperDef for EvalHelper {
258 #[cfg_attr(
259 feature = "tracing",
260 tracing::instrument(
261 level = "trace",
262 skip_all, err,
263 fields(helper.params = ?helper.params())
264 )
265 )]
266 fn call_inner<'reg: 'rc, 'rc>(
267 &self,
268 helper: &Helper<'rc>,
269 reg: &'reg Handlebars<'reg>,
270 ctx: &'rc Context,
271 render_ctx: &mut RenderContext<'reg, 'rc>,
272 ) -> Result<ScopedJson<'rc>, RenderError> {
273 let partial_name = helper
274 .param(0)
275 .ok_or(RenderErrorReason::ParamNotFoundForIndex(Self::NAME, 0))?;
276 let partial_name = partial_name.value().as_str().ok_or_else(|| {
277 RenderErrorReason::ParamTypeMismatchForName(
278 Self::NAME,
279 "0".to_owned(),
280 "string".to_owned(),
281 )
282 })?;
283
284 let partial = render_ctx
285 .get_partial(partial_name)
286 .ok_or_else(|| RenderErrorReason::PartialNotFound(partial_name.to_owned()))?;
287
288 let object: serde_json::Map<String, Json> = helper
289 .hash()
290 .iter()
291 .map(|(&name, value)| (name.to_owned(), value.value().clone()))
292 .collect();
293
294 let mut render_ctx = render_ctx.clone();
295 while render_ctx.block().is_some() {
296 render_ctx.pop_block();
297 }
298 let mut block_ctx = BlockContext::new();
299 block_ctx.set_base_value(Json::from(object));
300 render_ctx.push_block(block_ctx);
301
302 let mut output = StringOutput::new();
303 partial.render(reg, ctx, &mut render_ctx, &mut output)?;
304 let json_string = output.into_string()?;
305 let json: Json = serde_json::from_str(&json_string).map_err(RenderErrorReason::from)?;
306 Ok(ScopedJson::Derived(json))
307 }
308}
309
310#[derive(Debug)]
311struct LineCounter;
312
313impl LineCounter {
314 const NAME: &'static str = "count_lines";
315}
316
317impl HelperDef for LineCounter {
318 #[cfg_attr(
319 feature = "tracing",
320 tracing::instrument(
321 level = "trace",
322 skip_all, err,
323 fields(helper.params = ?helper.params(), helper.format = ?helper.hash_get("format"))
324 )
325 )]
326 fn call_inner<'reg: 'rc, 'rc>(
327 &self,
328 helper: &Helper<'rc>,
329 _: &'reg Handlebars<'reg>,
330 _: &'rc Context,
331 _: &mut RenderContext<'reg, 'rc>,
332 ) -> Result<ScopedJson<'rc>, RenderError> {
333 let string = helper
334 .param(0)
335 .ok_or(RenderErrorReason::ParamNotFoundForIndex(Self::NAME, 0))?;
336 let string = string.value().as_str().ok_or_else(|| {
337 RenderErrorReason::ParamTypeMismatchForName(
338 Self::NAME,
339 "0".to_owned(),
340 "string".to_owned(),
341 )
342 })?;
343 let is_html = helper
344 .hash_get("format")
345 .is_some_and(|format| format.value().as_str() == Some("html"));
346
347 let mut lines = bytecount::count(string.as_bytes(), b'\n');
348 if is_html {
349 lines += string.matches("<br/>").count();
350 }
351 if !string.is_empty() && !string.ends_with('\n') {
352 lines += 1;
353 }
354
355 let lines = u64::try_from(lines)
356 .map_err(|err| RenderErrorReason::Other(format!("cannot convert length: {err}")))?;
357 Ok(ScopedJson::Derived(lines.into()))
358 }
359}
360
361#[derive(Debug)]
362struct LineSplitter;
363
364impl LineSplitter {
365 const NAME: &'static str = "split_lines";
366}
367
368impl HelperDef for LineSplitter {
369 #[cfg_attr(
370 feature = "tracing",
371 tracing::instrument(
372 level = "trace",
373 skip_all, err,
374 fields(helper.params = ?helper.params())
375 )
376 )]
377 fn call_inner<'reg: 'rc, 'rc>(
378 &self,
379 helper: &Helper<'rc>,
380 _: &'reg Handlebars<'reg>,
381 _: &'rc Context,
382 _: &mut RenderContext<'reg, 'rc>,
383 ) -> Result<ScopedJson<'rc>, RenderError> {
384 let string = helper
385 .param(0)
386 .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex(Self::NAME, 0))?;
387 let string = string.value().as_str().ok_or_else(|| {
388 RenderErrorReason::ParamTypeMismatchForName(
389 Self::NAME,
390 "0".to_owned(),
391 "string".to_owned(),
392 )
393 })?;
394
395 let lines = string.split('\n');
396 let mut lines: Vec<_> = lines.map(Json::from).collect();
397 if let Some(Json::String(s)) = lines.last() {
399 if s.is_empty() {
400 lines.pop();
401 }
402 }
403
404 Ok(ScopedJson::Derived(lines.into()))
405 }
406}
407
408#[derive(Debug)]
409struct RangeHelper;
410
411impl RangeHelper {
412 const NAME: &'static str = "range";
413
414 fn coerce_value(value: &Json) -> Option<i64> {
415 value
416 .as_i64()
417 .or_else(|| value.as_f64().and_then(|val| to_i64(val.round())))
418 }
419}
420
421impl HelperDef for RangeHelper {
422 #[cfg_attr(
423 feature = "tracing",
424 tracing::instrument(
425 level = "trace",
426 skip_all, err,
427 fields(helper.params = ?helper.params())
428 )
429 )]
430 fn call_inner<'reg: 'rc, 'rc>(
431 &self,
432 helper: &Helper<'rc>,
433 _: &'reg Handlebars<'reg>,
434 _: &'rc Context,
435 _: &mut RenderContext<'reg, 'rc>,
436 ) -> Result<ScopedJson<'rc>, RenderError> {
437 let from = helper
438 .param(0)
439 .ok_or(RenderErrorReason::ParamNotFoundForIndex(Self::NAME, 0))?;
440 let from = Self::coerce_value(from.value()).ok_or_else(|| {
441 RenderErrorReason::ParamTypeMismatchForName(
442 Self::NAME,
443 "0".to_owned(),
444 "integer".to_owned(),
445 )
446 })?;
447 let to = helper
448 .param(1)
449 .ok_or(RenderErrorReason::ParamNotFoundForIndex(Self::NAME, 1))?;
450 let to = Self::coerce_value(to.value()).ok_or_else(|| {
451 RenderErrorReason::ParamTypeMismatchForName(
452 Self::NAME,
453 "1".to_owned(),
454 "integer".to_owned(),
455 )
456 })?;
457
458 let json: Vec<_> = (from..to).map(Json::from).collect();
459 Ok(ScopedJson::Derived(json.into()))
460 }
461}
462
463pub(super) fn register_helpers(reg: &mut Handlebars<'_>) {
464 reg.register_helper("add", Box::new(OpsHelper::Add));
465 reg.register_helper("sub", Box::new(OpsHelper::Sub));
466 reg.register_helper("mul", Box::new(OpsHelper::Mul));
467 reg.register_helper("div", Box::new(OpsHelper::Div));
468 reg.register_helper(LineCounter::NAME, Box::new(LineCounter));
469 reg.register_helper(LineSplitter::NAME, Box::new(LineSplitter));
470 reg.register_helper(RangeHelper::NAME, Box::new(RangeHelper));
471 reg.register_helper("scope", Box::new(ScopeHelper));
472 reg.register_helper(EvalHelper::NAME, Box::new(EvalHelper));
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn scope_helper_basics() {
481 let template = "{{#scope test_var=1}}Test var is: {{test_var}}{{/scope}}";
482 let mut handlebars = Handlebars::new();
483 handlebars.set_strict_mode(true);
484 handlebars.register_helper("scope", Box::new(ScopeHelper));
485 let data = serde_json::json!({ "test": 3 });
486 let rendered = handlebars.render_template(template, &data).unwrap();
487 assert_eq!(rendered, "Test var is: 1");
488 }
489
490 #[test]
491 fn reassigning_scope_vars() {
492 let template = r#"
493 {{#scope test_var="test"}}
494 {{#test_var}}"{{test_var}} value"{{/test_var}}
495 Test var is: {{test_var}}
496 {{/scope}}
497 "#;
498
499 let mut handlebars = Handlebars::new();
500 handlebars.register_helper("scope", Box::new(ScopeHelper));
501 let data = serde_json::json!({ "test": 3 });
502 let rendered = handlebars.render_template(template, &data).unwrap();
503 assert_eq!(rendered.trim(), "Test var is: test value");
504 }
505
506 #[test]
507 fn scope_helper_with_control_flow() {
508 let template = r#"
509 {{#scope result=""}}
510 {{#each values}}
511 {{#if @first}}
512 {{result set=this}}
513 {{else}}
514 {{#result}}"{{result}}, {{this}}"{{/result}}
515 {{/if}}
516 {{/each}}
517 Concatenated: {{result}}
518 {{/scope}}
519 "#;
520
521 let mut handlebars = Handlebars::new();
522 handlebars.set_strict_mode(true);
523 handlebars.register_helper("scope", Box::new(ScopeHelper));
524 let data = serde_json::json!({ "values": ["foo", "bar", "baz"] });
525 let rendered = handlebars.render_template(template, &data).unwrap();
526 assert_eq!(rendered.trim(), "Concatenated: foo, bar, baz");
527 }
528
529 #[test]
530 fn add_helper_basics() {
531 let template = "{{add 1 2 5}}";
532 let mut handlebars = Handlebars::new();
533 handlebars.set_strict_mode(true);
534 handlebars.register_helper("add", Box::new(OpsHelper::Add));
535 let rendered = handlebars.render_template(template, &()).unwrap();
536 assert_eq!(rendered, "8");
537 }
538
539 #[test]
540 fn add_with_scope_var() {
541 let template = "
542 {{#scope lines=0 margins=0}}
543 {{#each values}}
544 {{lines set=(add (lines) input.line_count output.line_count)}}
545 {{#if (eq output.line_count 0) }}
546 {{margins set=(add (margins) 1)}}
547 {{else}}
548 {{margins set=(add (margins) 2)}}
549 {{/if}}
550 {{/each}}
551 {{lines}}, {{margins}}
552 {{/scope}}
553 ";
554
555 let mut handlebars = Handlebars::new();
556 handlebars.set_strict_mode(true);
557 handlebars.register_helper("scope", Box::new(ScopeHelper));
558 handlebars.register_helper("add", Box::new(OpsHelper::Add));
559
560 let data = serde_json::json!({
561 "values": [{
562 "input": { "line_count": 1 },
563 "output": { "line_count": 2 },
564 }, {
565 "input": { "line_count": 2 },
566 "output": { "line_count": 0 },
567 }]
568 });
569 let rendered = handlebars.render_template(template, &data).unwrap();
570 assert_eq!(rendered.trim(), "5, 3");
571 }
572
573 #[test]
574 fn rounding_in_arithmetic_helpers() {
575 let template = r#"
576 {{div x y}}, {{div x y round=true}}, {{div x y round="down"}}, {{div x y round="up"}}
577 "#;
578 let mut handlebars = Handlebars::new();
579 handlebars.set_strict_mode(true);
580 handlebars.register_helper("div", Box::new(OpsHelper::Div));
581
582 let data = serde_json::json!({ "x": 9, "y": 4 });
583 let rendered = handlebars.render_template(template, &data).unwrap();
584 assert_eq!(rendered.trim(), "2.25, 2, 2, 3");
585 }
586
587 #[test]
588 fn eval_basics() {
589 let template = r#"
590 {{#*inline "define_constants"}}
591 {
592 {{! Bottom margin for each input or output block }}
593 "BLOCK_MARGIN": 6,
594 "USER_INPUT_PADDING": 10
595 }
596 {{/inline}}
597 {{#with this as |$|}}
598 {{#with (eval "define_constants") as |const|}}
599 {{#with $}}
600 {{margin}}: {{const.BLOCK_MARGIN}}px;
601 {{/with}}
602 {{/with}}
603 {{/with}}
604 "#;
605
606 let mut handlebars = Handlebars::new();
607 handlebars.set_strict_mode(true);
608 handlebars.register_helper("eval", Box::new(EvalHelper));
609 let data = serde_json::json!({ "margin": "margin" });
610 let rendered = handlebars.render_template(template, &data).unwrap();
611 assert_eq!(rendered.trim(), "margin: 6px;");
612 }
613
614 #[test]
615 fn eval_with_args() {
616 let template = r#"
617 {{#*inline "add_numbers"}}
618 {{#scope sum=0}}
619 {{#each numbers}}
620 {{sum set=(add (sum) this)}}
621 {{/each}}
622 {{sum}}
623 {{/scope}}
624 {{/inline}}
625 {{#with this as |$|}}
626 {{#with (eval "add_numbers" numbers=$.num) as |sum|}}
627 {{#with (eval "add_numbers" numbers=$.num) as |other_sum|}}
628 sum={{sum}}, other_sum={{other_sum}}
629 {{/with}}
630 {{/with}}
631 {{/with}}
632 "#;
633
634 let mut handlebars = Handlebars::new();
635 handlebars.set_strict_mode(true);
636 handlebars.register_helper("scope", Box::new(ScopeHelper));
637 handlebars.register_helper("eval", Box::new(EvalHelper));
638 handlebars.register_helper("add", Box::new(OpsHelper::Add));
639 let data = serde_json::json!({ "num": [1, 2, 3, 4] });
640 let rendered = handlebars.render_template(template, &data).unwrap();
641 assert_eq!(rendered.trim(), "sum=10, other_sum=10");
642 }
643
644 #[test]
645 fn line_counter() {
646 let template = r#"
647 {{count_lines text}}, {{count_lines text format="html"}}
648 "#;
649 let text = "test\ntest<br/>test";
650
651 let mut handlebars = Handlebars::new();
652 handlebars.set_strict_mode(true);
653 handlebars.register_helper("count_lines", Box::new(LineCounter));
654 let data = serde_json::json!({ "text": text });
655 let rendered = handlebars.render_template(template, &data).unwrap();
656 assert_eq!(rendered.trim(), "2, 3");
657 }
658
659 #[test]
660 fn line_splitter() {
661 let template = "{{#each (split_lines text)}}{{this}}<br/>{{/each}}";
662 let text = "test\nother test";
663
664 let mut handlebars = Handlebars::new();
665 handlebars.set_strict_mode(true);
666 handlebars.register_helper("split_lines", Box::new(LineSplitter));
667 let data = serde_json::json!({ "text": text });
668 let rendered = handlebars.render_template(template, &data).unwrap();
669 assert_eq!(rendered.trim(), "test<br/>other test<br/>");
670
671 let text = "test\nother test\n";
672 let data = serde_json::json!({ "text": text });
673 let rendered = handlebars.render_template(template, &data).unwrap();
674 assert_eq!(rendered.trim(), "test<br/>other test<br/>");
675 }
676
677 #[test]
678 fn range_helper_with_each_block() {
679 let template = "{{#each (range 0 4)}}{{@index}}: {{lookup ../xs @index}}, {{/each}}";
680
681 let mut handlebars = Handlebars::new();
682 handlebars.set_strict_mode(true);
683 handlebars.register_helper("range", Box::new(RangeHelper));
684 let data = serde_json::json!({ "xs": [2, 3, 5, 8] });
685 let rendered = handlebars.render_template(template, &data).unwrap();
686 assert_eq!(rendered.trim(), "0: 2, 1: 3, 2: 5, 3: 8,");
687 }
688}