Skip to main content

formualizer_eval/builtins/text/
trim_case_concat.rs

1use super::super::utils::ARG_ANY_ONE;
2use crate::args::ArgSchema;
3use crate::function::Function;
4use crate::traits::{ArgumentHandle, FunctionContext};
5use formualizer_common::{ExcelError, ExcelErrorKind, LiteralValue};
6use formualizer_macros::func_caps;
7
8fn scalar_like_value(arg: &ArgumentHandle<'_, '_>) -> Result<LiteralValue, ExcelError> {
9    Ok(match arg.value()? {
10        crate::traits::CalcValue::Scalar(v) => v,
11        crate::traits::CalcValue::Range(rv) => rv.get_cell(0, 0),
12        crate::traits::CalcValue::Callable(_) => LiteralValue::Error(
13            ExcelError::new(ExcelErrorKind::Calc).with_message("LAMBDA value must be invoked"),
14        ),
15    })
16}
17
18fn to_text<'a, 'b>(a: &ArgumentHandle<'a, 'b>) -> Result<String, ExcelError> {
19    let v = scalar_like_value(a)?;
20    Ok(match v {
21        LiteralValue::Text(s) => s,
22        LiteralValue::Empty => String::new(),
23        LiteralValue::Boolean(b) => {
24            if b {
25                "TRUE".into()
26            } else {
27                "FALSE".into()
28            }
29        }
30        LiteralValue::Int(i) => i.to_string(),
31        LiteralValue::Number(f) => {
32            let s = f.to_string();
33            if s.ends_with(".0") {
34                s[..s.len() - 2].into()
35            } else {
36                s
37            }
38        }
39        LiteralValue::Error(e) => return Err(e),
40        other => other.to_string(),
41    })
42}
43
44#[derive(Debug)]
45pub struct TrimFn;
46/// Removes leading/trailing whitespace and collapses internal runs to single spaces.
47///
48/// # Remarks
49/// - Leading and trailing whitespace is removed.
50/// - Consecutive whitespace inside the text is collapsed to one ASCII space.
51/// - Non-text inputs are coerced to text before trimming.
52/// - Errors are propagated unchanged.
53///
54/// # Examples
55///
56/// ```yaml,sandbox
57/// title: "Normalize spacing"
58/// formula: '=TRIM("  alpha   beta  ")'
59/// expected: "alpha beta"
60/// ```
61///
62/// ```yaml,sandbox
63/// title: "Already clean text"
64/// formula: '=TRIM("report")'
65/// expected: "report"
66/// ```
67///
68/// ```yaml,docs
69/// related:
70///   - CLEAN
71///   - TEXTJOIN
72///   - SUBSTITUTE
73/// faq:
74///   - q: "What whitespace does TRIM normalize?"
75///     a: "It trims edges and collapses internal whitespace runs to single spaces."
76/// ```
77/// [formualizer-docgen:schema:start]
78/// Name: TRIM
79/// Type: TrimFn
80/// Min args: 1
81/// Max args: 1
82/// Variadic: false
83/// Signature: TRIM(arg1: any@scalar)
84/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
85/// Caps: PURE
86/// [formualizer-docgen:schema:end]
87impl Function for TrimFn {
88    func_caps!(PURE);
89    fn name(&self) -> &'static str {
90        "TRIM"
91    }
92    fn min_args(&self) -> usize {
93        1
94    }
95    fn arg_schema(&self) -> &'static [ArgSchema] {
96        &ARG_ANY_ONE[..]
97    }
98    fn eval<'a, 'b, 'c>(
99        &self,
100        args: &'c [ArgumentHandle<'a, 'b>],
101        _: &dyn FunctionContext<'b>,
102    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
103        let s = to_text(&args[0])?;
104        let mut out = String::new();
105        let mut prev_space = false;
106        for ch in s.chars() {
107            if ch.is_whitespace() {
108                prev_space = true;
109            } else {
110                if prev_space && !out.is_empty() {
111                    out.push(' ');
112                }
113                out.push(ch);
114                prev_space = false;
115            }
116        }
117        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(
118            out.trim().into(),
119        )))
120    }
121}
122
123#[derive(Debug)]
124pub struct UpperFn;
125/// Converts text to uppercase.
126///
127/// # Remarks
128/// - Uses ASCII uppercasing semantics in this implementation.
129/// - Numbers and booleans are first converted to text.
130/// - Errors are propagated unchanged.
131///
132/// # Examples
133///
134/// ```yaml,sandbox
135/// title: "Uppercase letters"
136/// formula: '=UPPER("Quarterly report")'
137/// expected: "QUARTERLY REPORT"
138/// ```
139///
140/// ```yaml,sandbox
141/// title: "Number coerced to text"
142/// formula: '=UPPER(123)'
143/// expected: "123"
144/// ```
145///
146/// ```yaml,docs
147/// related:
148///   - LOWER
149///   - PROPER
150///   - EXACT
151/// faq:
152///   - q: "Is uppercasing fully Unicode-aware?"
153///     a: "This implementation uses ASCII uppercasing semantics, so non-ASCII case rules are limited."
154/// ```
155/// [formualizer-docgen:schema:start]
156/// Name: UPPER
157/// Type: UpperFn
158/// Min args: 1
159/// Max args: 1
160/// Variadic: false
161/// Signature: UPPER(arg1: any@scalar)
162/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
163/// Caps: PURE
164/// [formualizer-docgen:schema:end]
165impl Function for UpperFn {
166    func_caps!(PURE);
167    fn name(&self) -> &'static str {
168        "UPPER"
169    }
170    fn min_args(&self) -> usize {
171        1
172    }
173    fn arg_schema(&self) -> &'static [ArgSchema] {
174        &ARG_ANY_ONE[..]
175    }
176    fn eval<'a, 'b, 'c>(
177        &self,
178        args: &'c [ArgumentHandle<'a, 'b>],
179        _: &dyn FunctionContext<'b>,
180    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
181        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(
182            to_text(&args[0])?.to_ascii_uppercase(),
183        )))
184    }
185}
186#[derive(Debug)]
187pub struct LowerFn;
188/// Converts text to lowercase.
189///
190/// # Remarks
191/// - Uses ASCII lowercasing semantics in this implementation.
192/// - Numbers and booleans are first converted to text.
193/// - Errors are propagated unchanged.
194///
195/// # Examples
196///
197/// ```yaml,sandbox
198/// title: "Lowercase letters"
199/// formula: '=LOWER("Data PIPELINE")'
200/// expected: "data pipeline"
201/// ```
202///
203/// ```yaml,sandbox
204/// title: "Boolean coerced to text"
205/// formula: '=LOWER(TRUE)'
206/// expected: "true"
207/// ```
208///
209/// ```yaml,docs
210/// related:
211///   - UPPER
212///   - PROPER
213///   - EXACT
214/// faq:
215///   - q: "How are booleans handled by LOWER?"
216///     a: "Inputs are coerced to text first, so TRUE/FALSE become lowercase string values."
217/// ```
218/// [formualizer-docgen:schema:start]
219/// Name: LOWER
220/// Type: LowerFn
221/// Min args: 1
222/// Max args: 1
223/// Variadic: false
224/// Signature: LOWER(arg1: any@scalar)
225/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
226/// Caps: PURE
227/// [formualizer-docgen:schema:end]
228impl Function for LowerFn {
229    func_caps!(PURE);
230    fn name(&self) -> &'static str {
231        "LOWER"
232    }
233    fn min_args(&self) -> usize {
234        1
235    }
236    fn arg_schema(&self) -> &'static [ArgSchema] {
237        &ARG_ANY_ONE[..]
238    }
239    fn eval<'a, 'b, 'c>(
240        &self,
241        args: &'c [ArgumentHandle<'a, 'b>],
242        _: &dyn FunctionContext<'b>,
243    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
244        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(
245            to_text(&args[0])?.to_ascii_lowercase(),
246        )))
247    }
248}
249#[derive(Debug)]
250pub struct ProperFn;
251/// Capitalizes the first letter of each alphanumeric word.
252///
253/// # Remarks
254/// - Word boundaries are reset by non-alphanumeric characters.
255/// - Internal letters in each word are lowercased.
256/// - Non-text inputs are coerced to text.
257/// - Errors are propagated unchanged.
258///
259/// # Examples
260///
261/// ```yaml,sandbox
262/// title: "Title case simple phrase"
263/// formula: '=PROPER("hello world")'
264/// expected: "Hello World"
265/// ```
266///
267/// ```yaml,sandbox
268/// title: "Hyphen-separated words"
269/// formula: '=PROPER("north-east REGION")'
270/// expected: "North-East Region"
271/// ```
272///
273/// ```yaml,docs
274/// related:
275///   - UPPER
276///   - LOWER
277///   - TRIM
278/// faq:
279///   - q: "How are word boundaries determined?"
280///     a: "Any non-alphanumeric character starts a new word boundary for capitalization."
281/// ```
282/// [formualizer-docgen:schema:start]
283/// Name: PROPER
284/// Type: ProperFn
285/// Min args: 1
286/// Max args: 1
287/// Variadic: false
288/// Signature: PROPER(arg1: any@scalar)
289/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
290/// Caps: PURE
291/// [formualizer-docgen:schema:end]
292impl Function for ProperFn {
293    func_caps!(PURE);
294    fn name(&self) -> &'static str {
295        "PROPER"
296    }
297    fn min_args(&self) -> usize {
298        1
299    }
300    fn arg_schema(&self) -> &'static [ArgSchema] {
301        &ARG_ANY_ONE[..]
302    }
303    fn eval<'a, 'b, 'c>(
304        &self,
305        args: &'c [ArgumentHandle<'a, 'b>],
306        _: &dyn FunctionContext<'b>,
307    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
308        let s = to_text(&args[0])?;
309        let mut out = String::new();
310        let mut new_word = true;
311        for ch in s.chars() {
312            if ch.is_alphanumeric() {
313                if new_word {
314                    for c in ch.to_uppercase() {
315                        out.push(c);
316                    }
317                } else {
318                    for c in ch.to_lowercase() {
319                        out.push(c);
320                    }
321                }
322                new_word = false;
323            } else {
324                out.push(ch);
325                new_word = true;
326            }
327        }
328        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(out)))
329    }
330}
331
332// CONCAT(text1, text2, ...)
333#[derive(Debug)]
334pub struct ConcatFn;
335/// Concatenates multiple values into one text string.
336///
337/// # Remarks
338/// - Accepts one or more arguments.
339/// - Blank values contribute an empty string.
340/// - Numbers and booleans are coerced to text.
341/// - Errors are propagated as soon as encountered.
342///
343/// # Examples
344///
345/// ```yaml,sandbox
346/// title: "Join text pieces"
347/// formula: '=CONCAT("Q", 1, "-", "2026")'
348/// expected: "Q1-2026"
349/// ```
350///
351/// ```yaml,sandbox
352/// title: "Concatenate with blanks"
353/// formula: '=CONCAT("A", "", "B")'
354/// expected: "AB"
355/// ```
356///
357/// ```yaml,docs
358/// related:
359///   - CONCATENATE
360///   - TEXTJOIN
361///   - VALUE
362/// faq:
363///   - q: "Do blank arguments add separators or characters?"
364///     a: "No. CONCAT appends each value directly, and blanks contribute an empty string."
365/// ```
366/// [formualizer-docgen:schema:start]
367/// Name: CONCAT
368/// Type: ConcatFn
369/// Min args: 1
370/// Max args: variadic
371/// Variadic: true
372/// Signature: CONCAT(arg1...: any@scalar)
373/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
374/// Caps: PURE
375/// [formualizer-docgen:schema:end]
376impl Function for ConcatFn {
377    func_caps!(PURE);
378    fn name(&self) -> &'static str {
379        "CONCAT"
380    }
381    fn min_args(&self) -> usize {
382        1
383    }
384    fn variadic(&self) -> bool {
385        true
386    }
387    fn arg_schema(&self) -> &'static [ArgSchema] {
388        &ARG_ANY_ONE[..]
389    }
390    fn eval<'a, 'b, 'c>(
391        &self,
392        args: &'c [ArgumentHandle<'a, 'b>],
393        _: &dyn FunctionContext<'b>,
394    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
395        let mut out = String::new();
396        for a in args {
397            out.push_str(&to_text(a)?);
398        }
399        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(out)))
400    }
401}
402// CONCATENATE (alias semantics)
403#[derive(Debug)]
404pub struct ConcatenateFn;
405/// Legacy alias for `CONCAT` that joins multiple values as text.
406///
407/// # Remarks
408/// - Semantics match `CONCAT` in this implementation.
409/// - Blank values contribute an empty string.
410/// - Numbers and booleans are coerced to text.
411/// - Errors are propagated as soon as encountered.
412///
413/// # Examples
414///
415/// ```yaml,sandbox
416/// title: "Legacy concatenate behavior"
417/// formula: '=CONCATENATE("Jan", "-", 2026)'
418/// expected: "Jan-2026"
419/// ```
420///
421/// ```yaml,sandbox
422/// title: "Boolean coercion"
423/// formula: '=CONCATENATE("Flag:", TRUE)'
424/// expected: "Flag:TRUE"
425/// ```
426///
427/// ```yaml,docs
428/// related:
429///   - CONCAT
430///   - TEXTJOIN
431///   - VALUE
432/// faq:
433///   - q: "Is CONCATENATE behavior different from CONCAT here?"
434///     a: "No. In this engine CONCATENATE uses the same join semantics as CONCAT."
435/// ```
436/// [formualizer-docgen:schema:start]
437/// Name: CONCATENATE
438/// Type: ConcatenateFn
439/// Min args: 1
440/// Max args: variadic
441/// Variadic: true
442/// Signature: CONCATENATE(arg1...: any@scalar)
443/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
444/// Caps: PURE
445/// [formualizer-docgen:schema:end]
446impl Function for ConcatenateFn {
447    func_caps!(PURE);
448    fn name(&self) -> &'static str {
449        "CONCATENATE"
450    }
451    fn min_args(&self) -> usize {
452        1
453    }
454    fn variadic(&self) -> bool {
455        true
456    }
457    fn arg_schema(&self) -> &'static [ArgSchema] {
458        &ARG_ANY_ONE[..]
459    }
460    fn eval<'a, 'b, 'c>(
461        &self,
462        args: &'c [ArgumentHandle<'a, 'b>],
463        ctx: &dyn FunctionContext<'b>,
464    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
465        ConcatFn.eval(args, ctx)
466    }
467}
468
469// TEXTJOIN(delimiter, ignore_empty, text1, [text2, ...])
470#[derive(Debug)]
471pub struct TextJoinFn;
472/// Joins text values using a delimiter, with optional empty-value filtering.
473///
474/// `TEXTJOIN(delimiter, ignore_empty, text1, ...)` is useful for building labels and lists.
475///
476/// # Remarks
477/// - `ignore_empty=TRUE` skips empty strings and empty cells.
478/// - `ignore_empty=FALSE` includes empty items, which can produce adjacent delimiters.
479/// - Delimiter and values are coerced to text.
480/// - Any error in inputs propagates immediately.
481///
482/// # Examples
483///
484/// ```yaml,sandbox
485/// title: "Ignore empty entries"
486/// formula: '=TEXTJOIN(",", TRUE, "a", "", "c")'
487/// expected: "a,c"
488/// ```
489///
490/// ```yaml,sandbox
491/// title: "Keep empty entries"
492/// formula: '=TEXTJOIN("-", FALSE, "a", "", "c")'
493/// expected: "a--c"
494/// ```
495///
496/// ```yaml,docs
497/// related:
498///   - CONCAT
499///   - CONCATENATE
500///   - TEXTSPLIT
501/// faq:
502///   - q: "What does ignore_empty change?"
503///     a: "TRUE skips empty values; FALSE keeps them, which can create adjacent delimiters."
504/// ```
505/// [formualizer-docgen:schema:start]
506/// Name: TEXTJOIN
507/// Type: TextJoinFn
508/// Min args: 3
509/// Max args: variadic
510/// Variadic: true
511/// Signature: TEXTJOIN(arg1...: any@scalar)
512/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}
513/// Caps: PURE
514/// [formualizer-docgen:schema:end]
515impl Function for TextJoinFn {
516    func_caps!(PURE);
517    fn name(&self) -> &'static str {
518        "TEXTJOIN"
519    }
520    fn min_args(&self) -> usize {
521        3
522    }
523    fn variadic(&self) -> bool {
524        true
525    }
526    fn arg_schema(&self) -> &'static [ArgSchema] {
527        &ARG_ANY_ONE[..]
528    }
529    fn eval<'a, 'b, 'c>(
530        &self,
531        args: &'c [ArgumentHandle<'a, 'b>],
532        _: &dyn FunctionContext<'b>,
533    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
534        if args.len() < 3 {
535            return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(
536                ExcelError::new_value(),
537            )));
538        }
539
540        // Get delimiter
541        let delimiter = to_text(&args[0])?;
542
543        // Get ignore_empty flag
544        let ignore_empty = match scalar_like_value(&args[1])? {
545            LiteralValue::Boolean(b) => b,
546            LiteralValue::Int(i) => i != 0,
547            LiteralValue::Number(f) => f != 0.0,
548            LiteralValue::Text(t) => t.to_uppercase() == "TRUE",
549            LiteralValue::Empty => false,
550            LiteralValue::Error(e) => {
551                return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
552            }
553            _ => false,
554        };
555
556        // Collect text values
557        let mut parts = Vec::new();
558        for arg in args.iter().skip(2) {
559            match scalar_like_value(arg)? {
560                LiteralValue::Error(e) => {
561                    return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
562                }
563                LiteralValue::Empty => {
564                    if !ignore_empty {
565                        parts.push(String::new());
566                    }
567                }
568                v => {
569                    let s = match v {
570                        LiteralValue::Text(t) => t,
571                        LiteralValue::Boolean(b) => {
572                            if b {
573                                "TRUE".to_string()
574                            } else {
575                                "FALSE".to_string()
576                            }
577                        }
578                        LiteralValue::Int(i) => i.to_string(),
579                        LiteralValue::Number(f) => f.to_string(),
580                        _ => v.to_string(),
581                    };
582                    if !ignore_empty || !s.is_empty() {
583                        parts.push(s);
584                    }
585                }
586            }
587        }
588
589        Ok(crate::traits::CalcValue::Scalar(LiteralValue::Text(
590            parts.join(&delimiter),
591        )))
592    }
593}
594
595pub fn register_builtins() {
596    use std::sync::Arc;
597    crate::function_registry::register_function(Arc::new(TrimFn));
598    crate::function_registry::register_function(Arc::new(UpperFn));
599    crate::function_registry::register_function(Arc::new(LowerFn));
600    crate::function_registry::register_function(Arc::new(ProperFn));
601    crate::function_registry::register_function(Arc::new(ConcatFn));
602    crate::function_registry::register_function(Arc::new(ConcatenateFn));
603    crate::function_registry::register_function(Arc::new(TextJoinFn));
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::test_workbook::TestWorkbook;
610    use crate::traits::ArgumentHandle;
611    use formualizer_common::LiteralValue;
612    use formualizer_parse::parser::{ASTNode, ASTNodeType};
613    fn lit(v: LiteralValue) -> ASTNode {
614        ASTNode::new(ASTNodeType::Literal(v), None)
615    }
616    #[test]
617    fn trim_basic() {
618        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(TrimFn));
619        let ctx = wb.interpreter();
620        let f = ctx.context.get_function("", "TRIM").unwrap();
621        let s = lit(LiteralValue::Text("  a   b  ".into()));
622        let out = f
623            .dispatch(
624                &[ArgumentHandle::new(&s, &ctx)],
625                &ctx.function_context(None),
626            )
627            .unwrap();
628        assert_eq!(out, LiteralValue::Text("a b".into()));
629    }
630    #[test]
631    fn concat_variants() {
632        let wb = TestWorkbook::new()
633            .with_function(std::sync::Arc::new(ConcatFn))
634            .with_function(std::sync::Arc::new(ConcatenateFn));
635        let ctx = wb.interpreter();
636        let c = ctx.context.get_function("", "CONCAT").unwrap();
637        let ce = ctx.context.get_function("", "CONCATENATE").unwrap();
638        let a = lit(LiteralValue::Text("a".into()));
639        let b = lit(LiteralValue::Text("b".into()));
640        assert_eq!(
641            c.dispatch(
642                &[ArgumentHandle::new(&a, &ctx), ArgumentHandle::new(&b, &ctx)],
643                &ctx.function_context(None)
644            )
645            .unwrap()
646            .into_literal(),
647            LiteralValue::Text("ab".into())
648        );
649        assert_eq!(
650            ce.dispatch(
651                &[ArgumentHandle::new(&a, &ctx), ArgumentHandle::new(&b, &ctx)],
652                &ctx.function_context(None)
653            )
654            .unwrap()
655            .into_literal(),
656            LiteralValue::Text("ab".into())
657        );
658    }
659
660    #[test]
661    fn textjoin_basic() {
662        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(TextJoinFn));
663        let ctx = wb.interpreter();
664        let f = ctx.context.get_function("", "TEXTJOIN").unwrap();
665        let delim = lit(LiteralValue::Text(",".into()));
666        let ignore = lit(LiteralValue::Boolean(true));
667        let a = lit(LiteralValue::Text("a".into()));
668        let b = lit(LiteralValue::Text("b".into()));
669        let c = lit(LiteralValue::Empty);
670        let d = lit(LiteralValue::Text("d".into()));
671        let out = f
672            .dispatch(
673                &[
674                    ArgumentHandle::new(&delim, &ctx),
675                    ArgumentHandle::new(&ignore, &ctx),
676                    ArgumentHandle::new(&a, &ctx),
677                    ArgumentHandle::new(&b, &ctx),
678                    ArgumentHandle::new(&c, &ctx),
679                    ArgumentHandle::new(&d, &ctx),
680                ],
681                &ctx.function_context(None),
682            )
683            .unwrap();
684        assert_eq!(out, LiteralValue::Text("a,b,d".into()));
685    }
686
687    #[test]
688    fn textjoin_no_ignore() {
689        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(TextJoinFn));
690        let ctx = wb.interpreter();
691        let f = ctx.context.get_function("", "TEXTJOIN").unwrap();
692        let delim = lit(LiteralValue::Text("-".into()));
693        let ignore = lit(LiteralValue::Boolean(false));
694        let a = lit(LiteralValue::Text("a".into()));
695        let b = lit(LiteralValue::Empty);
696        let c = lit(LiteralValue::Text("c".into()));
697        let out = f
698            .dispatch(
699                &[
700                    ArgumentHandle::new(&delim, &ctx),
701                    ArgumentHandle::new(&ignore, &ctx),
702                    ArgumentHandle::new(&a, &ctx),
703                    ArgumentHandle::new(&b, &ctx),
704                    ArgumentHandle::new(&c, &ctx),
705                ],
706                &ctx.function_context(None),
707            )
708            .unwrap();
709        assert_eq!(out, LiteralValue::Text("a--c".into()));
710    }
711}