term_transcript/svg/
helpers.rs

1//! Custom Handlebars helpers.
2
3use 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/// Tries to convert an `f64` number to `i64` without precision loss.
12#[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                // Variable setter.
127                self.set_value(value.value().clone());
128                Ok(ScopedJson::Constant(&Json::Null))
129            } else {
130                // Variable getter.
131                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            // `unwrap`s are safe because of previous checks
161            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            // `unwrap`s are safe because of previous checks
171            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                // Try to present the value as `i64` (this could be beneficial for other helpers).
237                // If this doesn't work, present it as an original floating-point value.
238                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        // Remove the last empty line if necessary.
398        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}