Skip to main content

formualizer_eval/builtins/text/
array_text.rs

1//! Text array functions: TEXTSPLIT, VALUETOTEXT, ARRAYTOTEXT
2//!
3//! TEXTSPLIT: Splits text into a 2D array based on delimiters
4//! VALUETOTEXT: Converts a value to text representation
5//! ARRAYTOTEXT: Converts an array to text representation
6
7use super::super::utils::collapse_if_scalar;
8use crate::args::{ArgSchema, ShapeKind};
9use crate::function::Function;
10use crate::traits::{ArgumentHandle, CalcValue, FunctionContext};
11use formualizer_common::{ArgKind, CoercionPolicy, ExcelError, ExcelErrorKind, LiteralValue};
12use formualizer_macros::func_caps;
13
14fn scalar_like_value(arg: &ArgumentHandle<'_, '_>) -> Result<LiteralValue, ExcelError> {
15    Ok(match arg.value()? {
16        CalcValue::Scalar(v) => v,
17        CalcValue::Range(rv) => rv.get_cell(0, 0),
18        CalcValue::Callable(_) => LiteralValue::Error(
19            ExcelError::new(ExcelErrorKind::Calc).with_message("LAMBDA value must be invoked"),
20        ),
21    })
22}
23
24/// Coerce a LiteralValue to text
25fn coerce_text(v: &LiteralValue) -> String {
26    match v {
27        LiteralValue::Text(s) => s.clone(),
28        LiteralValue::Empty => String::new(),
29        LiteralValue::Boolean(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
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].to_string()
35            } else {
36                s
37            }
38        }
39        other => other.to_string(),
40    }
41}
42
43/// Get delimiters from an argument (can be single value or array)
44fn get_delimiters(arg: &ArgumentHandle<'_, '_>) -> Result<Vec<String>, ExcelError> {
45    let cv = arg.value()?;
46    match cv {
47        CalcValue::Scalar(v) => match v {
48            LiteralValue::Error(e) => Err(e),
49            LiteralValue::Array(arr) => {
50                let mut delims = Vec::new();
51                for row in arr {
52                    for cell in row {
53                        let s = coerce_text(&cell);
54                        if !s.is_empty() {
55                            delims.push(s);
56                        }
57                    }
58                }
59                Ok(delims)
60            }
61            other => {
62                let s = coerce_text(&other);
63                if s.is_empty() {
64                    Ok(vec![])
65                } else {
66                    Ok(vec![s])
67                }
68            }
69        },
70        CalcValue::Range(rv) => {
71            let mut delims = Vec::new();
72            rv.for_each_cell(&mut |cell| {
73                let s = coerce_text(cell);
74                if !s.is_empty() {
75                    delims.push(s);
76                }
77                Ok(())
78            })?;
79            Ok(delims)
80        }
81        CalcValue::Callable(_) => {
82            Err(ExcelError::new(ExcelErrorKind::Calc).with_message("LAMBDA value must be invoked"))
83        }
84    }
85}
86
87// ============================================================================
88// TEXTSPLIT - Split text into 2D array based on delimiters
89// ============================================================================
90
91fn arg_textsplit() -> Vec<ArgSchema> {
92    vec![
93        // text
94        ArgSchema {
95            kinds: smallvec::smallvec![ArgKind::Any],
96            required: true,
97            by_ref: false,
98            shape: ShapeKind::Scalar,
99            coercion: CoercionPolicy::None,
100            max: None,
101            repeating: None,
102            default: None,
103        },
104        // col_delimiter
105        ArgSchema {
106            kinds: smallvec::smallvec![ArgKind::Any],
107            required: true,
108            by_ref: false,
109            shape: ShapeKind::Scalar,
110            coercion: CoercionPolicy::None,
111            max: None,
112            repeating: None,
113            default: None,
114        },
115        // row_delimiter (optional)
116        ArgSchema {
117            kinds: smallvec::smallvec![ArgKind::Any],
118            required: false,
119            by_ref: false,
120            shape: ShapeKind::Scalar,
121            coercion: CoercionPolicy::None,
122            max: None,
123            repeating: None,
124            default: None,
125        },
126        // ignore_empty (optional, default FALSE)
127        ArgSchema {
128            kinds: smallvec::smallvec![ArgKind::Logical],
129            required: false,
130            by_ref: false,
131            shape: ShapeKind::Scalar,
132            coercion: CoercionPolicy::Logical,
133            max: None,
134            repeating: None,
135            default: Some(LiteralValue::Boolean(false)),
136        },
137        // match_mode (optional, default 0)
138        ArgSchema {
139            kinds: smallvec::smallvec![ArgKind::Number],
140            required: false,
141            by_ref: false,
142            shape: ShapeKind::Scalar,
143            coercion: CoercionPolicy::NumberLenientText,
144            max: None,
145            repeating: None,
146            default: Some(LiteralValue::Number(0.0)),
147        },
148        // pad_with (optional, default #N/A)
149        ArgSchema {
150            kinds: smallvec::smallvec![ArgKind::Any],
151            required: false,
152            by_ref: false,
153            shape: ShapeKind::Scalar,
154            coercion: CoercionPolicy::None,
155            max: None,
156            repeating: None,
157            default: Some(LiteralValue::Error(ExcelError::new(ExcelErrorKind::Na))),
158        },
159    ]
160}
161
162/// Split text using any of the delimiters, with optional case-insensitive matching
163fn split_by_delimiters(text: &str, delimiters: &[String], case_insensitive: bool) -> Vec<String> {
164    if delimiters.is_empty() {
165        return vec![text.to_string()];
166    }
167
168    let working_text = if case_insensitive {
169        text.to_lowercase()
170    } else {
171        text.to_string()
172    };
173
174    let delims_working: Vec<String> = if case_insensitive {
175        delimiters.iter().map(|d| d.to_lowercase()).collect()
176    } else {
177        delimiters.to_vec()
178    };
179
180    let mut result = Vec::new();
181    let mut current_start = 0;
182
183    while current_start < text.len() {
184        let mut earliest_match: Option<(usize, usize)> = None; // (position, delimiter_len)
185
186        for delim in &delims_working {
187            if delim.is_empty() {
188                continue;
189            }
190            if let Some(pos) = working_text[current_start..].find(delim.as_str()) {
191                let abs_pos = current_start + pos;
192                match earliest_match {
193                    None => earliest_match = Some((abs_pos, delim.len())),
194                    Some((ep, _)) if abs_pos < ep => earliest_match = Some((abs_pos, delim.len())),
195                    _ => {}
196                }
197            }
198        }
199
200        match earliest_match {
201            Some((pos, len)) => {
202                result.push(text[current_start..pos].to_string());
203                current_start = pos + len;
204            }
205            None => {
206                result.push(text[current_start..].to_string());
207                break;
208            }
209        }
210    }
211
212    // If we ended exactly at a delimiter, add empty string at end
213    if current_start == text.len() && !text.is_empty() {
214        let ends_with_delim = delims_working.iter().any(|d| {
215            if d.is_empty() {
216                return false;
217            }
218            working_text.ends_with(d.as_str())
219        });
220        if ends_with_delim {
221            result.push(String::new());
222        }
223    }
224
225    result
226}
227
228#[derive(Debug)]
229pub struct TextSplitFn;
230/// Splits text into a dynamic 2D array by column and optional row delimiters.
231///
232/// `TEXTSPLIT` supports multiple delimiters, optional case-insensitive matching, and output padding.
233///
234/// # Remarks
235/// - Column delimiter is required; row delimiter is optional.
236/// - `match_mode=0` is case-sensitive, `match_mode=1` is case-insensitive.
237/// - `ignore_empty=TRUE` drops empty segments created by adjacent delimiters.
238/// - Rows are padded to equal width using `pad_with` (default `#N/A`).
239///
240/// # Examples
241///
242/// ```yaml,sandbox
243/// title: "Split CSV row"
244/// formula: '=TEXTSPLIT("A,B,C", ",")'
245/// expected: "{A,B,C}"
246/// ```
247///
248/// ```yaml,sandbox
249/// title: "Row and column split with padding"
250/// formula: '=TEXTSPLIT("A,B;C", ",", ";")'
251/// expected: "{A,B;C,#N/A}"
252/// ```
253///
254/// ```yaml,docs
255/// related:
256///   - TEXTBEFORE
257///   - TEXTAFTER
258///   - TEXTJOIN
259/// faq:
260///   - q: "Is delimiter matching case-sensitive?"
261///     a: "Yes by default; set match_mode to 1 for case-insensitive delimiter matching."
262/// ```
263/// [formualizer-docgen:schema:start]
264/// Name: TEXTSPLIT
265/// Type: TextSplitFn
266/// Min args: 2
267/// Max args: 6
268/// Variadic: false
269/// Signature: TEXTSPLIT(arg1: any@scalar, arg2: any@scalar, arg3?: any@scalar, arg4?: logical@scalar, arg5?: number@scalar, arg6?: any@scalar)
270/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg3{kinds=any,required=false,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg4{kinds=logical,required=false,shape=scalar,by_ref=false,coercion=Logical,max=None,repeating=None,default=true}; arg5{kinds=number,required=false,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=true}; arg6{kinds=any,required=false,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=true}
271/// Caps: PURE
272/// [formualizer-docgen:schema:end]
273impl Function for TextSplitFn {
274    func_caps!(PURE);
275
276    fn name(&self) -> &'static str {
277        "TEXTSPLIT"
278    }
279
280    fn min_args(&self) -> usize {
281        2
282    }
283
284    fn arg_schema(&self) -> &'static [ArgSchema] {
285        use once_cell::sync::Lazy;
286        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(arg_textsplit);
287        &SCHEMA
288    }
289
290    fn eval<'a, 'b, 'c>(
291        &self,
292        args: &'c [ArgumentHandle<'a, 'b>],
293        ctx: &dyn FunctionContext<'b>,
294    ) -> Result<CalcValue<'b>, ExcelError> {
295        // Get text to split
296        let text_val = scalar_like_value(&args[0])?;
297        let text = match text_val {
298            LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
299            other => coerce_text(&other),
300        };
301
302        // Get column delimiters
303        let col_delimiters = get_delimiters(&args[1])?;
304
305        // Get optional row delimiters
306        let row_delimiters = if args.len() > 2 {
307            // Check if row_delimiter argument is provided and not omitted
308            let val = scalar_like_value(&args[2])?;
309            match val {
310                LiteralValue::Empty => vec![],
311                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
312                _ => get_delimiters(&args[2])?,
313            }
314        } else {
315            vec![]
316        };
317
318        // Get ignore_empty (default FALSE)
319        let ignore_empty = if args.len() > 3 {
320            match scalar_like_value(&args[3])? {
321                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
322                LiteralValue::Boolean(b) => b,
323                LiteralValue::Number(n) => n != 0.0,
324                LiteralValue::Int(i) => i != 0,
325                _ => false,
326            }
327        } else {
328            false
329        };
330
331        // Get match_mode (default 0 = case-sensitive)
332        let case_insensitive = if args.len() > 4 {
333            match scalar_like_value(&args[4])? {
334                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
335                LiteralValue::Number(n) => n.trunc() as i32 == 1,
336                LiteralValue::Int(i) => i == 1,
337                _ => false,
338            }
339        } else {
340            false
341        };
342
343        // Get pad_with (default #N/A)
344        let pad_with = if args.len() > 5 {
345            scalar_like_value(&args[5])?
346        } else {
347            LiteralValue::Error(ExcelError::new(ExcelErrorKind::Na))
348        };
349
350        // First, split by row delimiters (if any)
351        let row_parts = if row_delimiters.is_empty() {
352            vec![text.clone()]
353        } else {
354            split_by_delimiters(&text, &row_delimiters, case_insensitive)
355        };
356
357        // Then split each row by column delimiters
358        let mut rows: Vec<Vec<LiteralValue>> = Vec::new();
359        let mut max_cols = 0;
360
361        for row_text in row_parts {
362            if ignore_empty && row_text.is_empty() {
363                continue;
364            }
365
366            let col_parts = split_by_delimiters(&row_text, &col_delimiters, case_insensitive);
367
368            let row: Vec<LiteralValue> = if ignore_empty {
369                col_parts
370                    .into_iter()
371                    .filter(|s| !s.is_empty())
372                    .map(LiteralValue::Text)
373                    .collect()
374            } else {
375                col_parts.into_iter().map(LiteralValue::Text).collect()
376            };
377
378            if !row.is_empty() {
379                max_cols = max_cols.max(row.len());
380                rows.push(row);
381            }
382        }
383
384        // Handle empty result
385        if rows.is_empty() {
386            return Ok(CalcValue::Scalar(LiteralValue::Text(String::new())));
387        }
388
389        // Pad rows to same width
390        for row in &mut rows {
391            while row.len() < max_cols {
392                row.push(pad_with.clone());
393            }
394        }
395
396        Ok(collapse_if_scalar(rows, ctx.date_system()))
397    }
398}
399
400// ============================================================================
401// VALUETOTEXT - Convert value to text representation
402// ============================================================================
403
404fn arg_valuetotext() -> Vec<ArgSchema> {
405    vec![
406        // value
407        ArgSchema {
408            kinds: smallvec::smallvec![ArgKind::Any],
409            required: true,
410            by_ref: false,
411            shape: ShapeKind::Scalar,
412            coercion: CoercionPolicy::None,
413            max: None,
414            repeating: None,
415            default: None,
416        },
417        // format (optional, default 0=concise)
418        ArgSchema {
419            kinds: smallvec::smallvec![ArgKind::Number],
420            required: false,
421            by_ref: false,
422            shape: ShapeKind::Scalar,
423            coercion: CoercionPolicy::NumberLenientText,
424            max: None,
425            repeating: None,
426            default: Some(LiteralValue::Number(0.0)),
427        },
428    ]
429}
430
431/// Convert a single value to its text representation
432fn value_to_text_repr(v: &LiteralValue, strict: bool) -> String {
433    match v {
434        LiteralValue::Text(s) => {
435            if strict {
436                format!("\"{}\"", s)
437            } else {
438                s.clone()
439            }
440        }
441        LiteralValue::Number(n) => {
442            let s = n.to_string();
443            if s.ends_with(".0") {
444                s[..s.len() - 2].to_string()
445            } else {
446                s
447            }
448        }
449        LiteralValue::Int(i) => i.to_string(),
450        LiteralValue::Boolean(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
451        LiteralValue::Empty => String::new(),
452        LiteralValue::Error(e) => e.to_string(),
453        LiteralValue::Array(arr) => {
454            // For arrays, use array syntax
455            let rows: Vec<String> = arr
456                .iter()
457                .map(|row| {
458                    row.iter()
459                        .map(|cell| value_to_text_repr(cell, strict))
460                        .collect::<Vec<_>>()
461                        .join(",")
462                })
463                .collect();
464            format!("{{{}}}", rows.join(";"))
465        }
466        LiteralValue::Date(d) => d.format("%Y-%m-%d").to_string(),
467        LiteralValue::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
468        LiteralValue::Time(t) => t.format("%H:%M:%S").to_string(),
469        LiteralValue::Duration(dur) => {
470            let total_secs = dur.num_seconds();
471            let hours = total_secs / 3600;
472            let mins = (total_secs % 3600) / 60;
473            let secs = total_secs % 60;
474            format!("{}:{:02}:{:02}", hours, mins, secs)
475        }
476        LiteralValue::Pending => String::new(),
477    }
478}
479
480#[derive(Debug)]
481pub struct ValueToTextFn;
482/// Converts a value to text representation.
483///
484/// `VALUETOTEXT(value, [format])` supports concise (`0`) and strict (`1`) modes.
485///
486/// # Remarks
487/// - Concise mode (`0`) returns natural text for scalars.
488/// - Strict mode (`1`) adds explicit quoting for text and serializes arrays with braces.
489/// - In concise mode, error values are propagated as errors.
490/// - In strict mode, error values are rendered as their error text.
491///
492/// # Examples
493///
494/// ```yaml,sandbox
495/// title: "Concise text conversion"
496/// formula: '=VALUETOTEXT(123)'
497/// expected: "123"
498/// ```
499///
500/// ```yaml,sandbox
501/// title: "Strict quoting for text"
502/// formula: '=VALUETOTEXT("hello", 1)'
503/// expected: '"hello"'
504/// ```
505///
506/// ```yaml,docs
507/// related:
508///   - ARRAYTOTEXT
509///   - TEXT
510///   - VALUE
511/// faq:
512///   - q: "How are errors handled in concise vs strict mode?"
513///     a: "Concise mode returns the error, while strict mode converts the error to its text form."
514/// ```
515/// [formualizer-docgen:schema:start]
516/// Name: VALUETOTEXT
517/// Type: ValueToTextFn
518/// Min args: 1
519/// Max args: 2
520/// Variadic: false
521/// Signature: VALUETOTEXT(arg1: any@scalar, arg2?: number@scalar)
522/// Arg schema: arg1{kinds=any,required=true,shape=scalar,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=number,required=false,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=true}
523/// Caps: PURE
524/// [formualizer-docgen:schema:end]
525impl Function for ValueToTextFn {
526    func_caps!(PURE);
527
528    fn name(&self) -> &'static str {
529        "VALUETOTEXT"
530    }
531
532    fn min_args(&self) -> usize {
533        1
534    }
535
536    fn arg_schema(&self) -> &'static [ArgSchema] {
537        use once_cell::sync::Lazy;
538        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(arg_valuetotext);
539        &SCHEMA
540    }
541
542    fn eval<'a, 'b, 'c>(
543        &self,
544        args: &'c [ArgumentHandle<'a, 'b>],
545        _ctx: &dyn FunctionContext<'b>,
546    ) -> Result<CalcValue<'b>, ExcelError> {
547        // Get value
548        let value = scalar_like_value(&args[0])?;
549
550        // Get format (0=concise, 1=strict)
551        let format = if args.len() > 1 {
552            match scalar_like_value(&args[1])? {
553                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
554                LiteralValue::Number(n) => n.trunc() as i32,
555                LiteralValue::Int(i) => i as i32,
556                _ => 0,
557            }
558        } else {
559            0
560        };
561
562        let strict = format == 1;
563
564        // Handle error propagation for the value itself
565        if let LiteralValue::Error(e) = &value {
566            // In strict mode, errors become their text representation
567            // In concise mode, propagate the error
568            if strict {
569                return Ok(CalcValue::Scalar(LiteralValue::Text(e.to_string())));
570            } else {
571                return Ok(CalcValue::Scalar(LiteralValue::Error(e.clone())));
572            }
573        }
574
575        let result = value_to_text_repr(&value, strict);
576        Ok(CalcValue::Scalar(LiteralValue::Text(result)))
577    }
578}
579
580// ============================================================================
581// ARRAYTOTEXT - Convert array to text representation
582// ============================================================================
583
584fn arg_arraytotext() -> Vec<ArgSchema> {
585    vec![
586        // array
587        ArgSchema {
588            kinds: smallvec::smallvec![ArgKind::Any, ArgKind::Range],
589            required: true,
590            by_ref: false,
591            shape: ShapeKind::Range,
592            coercion: CoercionPolicy::None,
593            max: None,
594            repeating: None,
595            default: None,
596        },
597        // format (optional, default 0=concise)
598        ArgSchema {
599            kinds: smallvec::smallvec![ArgKind::Number],
600            required: false,
601            by_ref: false,
602            shape: ShapeKind::Scalar,
603            coercion: CoercionPolicy::NumberLenientText,
604            max: None,
605            repeating: None,
606            default: Some(LiteralValue::Number(0.0)),
607        },
608    ]
609}
610
611#[derive(Debug)]
612pub struct ArrayToTextFn;
613/// Converts an array or range into a text representation.
614///
615/// `ARRAYTOTEXT(array, [format])` supports concise (`0`) and strict (`1`) output styles.
616///
617/// # Remarks
618/// - Strict mode returns brace-delimited array syntax with row/column separators.
619/// - Concise mode flattens all values into a comma-space list.
620/// - Cells are converted using the same scalar text rules used by `VALUETOTEXT`.
621/// - Errors in scalar-only input propagate immediately.
622///
623/// # Examples
624///
625/// ```yaml,sandbox
626/// title: "Concise flattened output"
627/// formula: '=ARRAYTOTEXT({1,2,3})'
628/// expected: "1, 2, 3"
629/// ```
630///
631/// ```yaml,sandbox
632/// title: "Strict 2D representation"
633/// formula: '=ARRAYTOTEXT({1,2;3,4}, 1)'
634/// expected: "{1,2;3,4}"
635/// ```
636///
637/// ```yaml,docs
638/// related:
639///   - VALUETOTEXT
640///   - TEXTJOIN
641///   - TEXTSPLIT
642/// faq:
643///   - q: "What changes when format is 1?"
644///     a: "Format 1 returns brace-delimited array syntax; format 0 flattens values into a comma-space list."
645/// ```
646/// [formualizer-docgen:schema:start]
647/// Name: ARRAYTOTEXT
648/// Type: ArrayToTextFn
649/// Min args: 1
650/// Max args: 2
651/// Variadic: false
652/// Signature: ARRAYTOTEXT(arg1: any|range@range, arg2?: number@scalar)
653/// Arg schema: arg1{kinds=any|range,required=true,shape=range,by_ref=false,coercion=None,max=None,repeating=None,default=false}; arg2{kinds=number,required=false,shape=scalar,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=true}
654/// Caps: PURE
655/// [formualizer-docgen:schema:end]
656impl Function for ArrayToTextFn {
657    func_caps!(PURE);
658
659    fn name(&self) -> &'static str {
660        "ARRAYTOTEXT"
661    }
662
663    fn min_args(&self) -> usize {
664        1
665    }
666
667    fn arg_schema(&self) -> &'static [ArgSchema] {
668        use once_cell::sync::Lazy;
669        static SCHEMA: Lazy<Vec<ArgSchema>> = Lazy::new(arg_arraytotext);
670        &SCHEMA
671    }
672
673    fn eval<'a, 'b, 'c>(
674        &self,
675        args: &'c [ArgumentHandle<'a, 'b>],
676        _ctx: &dyn FunctionContext<'b>,
677    ) -> Result<CalcValue<'b>, ExcelError> {
678        // Get format (0=concise, 1=strict)
679        let format = if args.len() > 1 {
680            match scalar_like_value(&args[1])? {
681                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
682                LiteralValue::Number(n) => n.trunc() as i32,
683                LiteralValue::Int(i) => i as i32,
684                _ => 0,
685            }
686        } else {
687            0
688        };
689
690        let strict = format == 1;
691
692        // Try to get array from argument
693        let rows: Vec<Vec<LiteralValue>> = if let Ok(rv) = args[0].range_view() {
694            let (num_rows, num_cols) = rv.dims();
695            let mut result = Vec::with_capacity(num_rows);
696            for r in 0..num_rows {
697                let mut row = Vec::with_capacity(num_cols);
698                for c in 0..num_cols {
699                    row.push(rv.get_cell(r, c));
700                }
701                result.push(row);
702            }
703            result
704        } else {
705            let cv = args[0].value()?;
706            match cv.into_literal() {
707                LiteralValue::Array(arr) => arr,
708                LiteralValue::Error(e) => return Ok(CalcValue::Scalar(LiteralValue::Error(e))),
709                other => vec![vec![other]],
710            }
711        };
712
713        let result = if strict {
714            // Strict format: {value;value;...} with rows separated by semicolons
715            // and columns by commas, with strings quoted
716            let row_strs: Vec<String> = rows
717                .iter()
718                .map(|row| {
719                    row.iter()
720                        .map(|cell| value_to_text_repr(cell, true))
721                        .collect::<Vec<_>>()
722                        .join(",")
723                })
724                .collect();
725            format!("{{{}}}", row_strs.join(";"))
726        } else {
727            // Concise format: comma-separated values (all cells flattened)
728            let all_values: Vec<String> = rows
729                .iter()
730                .flat_map(|row| row.iter().map(|cell| value_to_text_repr(cell, false)))
731                .collect();
732            all_values.join(", ")
733        };
734
735        Ok(CalcValue::Scalar(LiteralValue::Text(result)))
736    }
737}
738
739// ============================================================================
740// Registration
741// ============================================================================
742
743pub fn register_builtins() {
744    use crate::function_registry::register_function;
745    use std::sync::Arc;
746
747    register_function(Arc::new(TextSplitFn));
748    register_function(Arc::new(ValueToTextFn));
749    register_function(Arc::new(ArrayToTextFn));
750}
751
752// ============================================================================
753// Tests
754// ============================================================================
755
756#[cfg(test)]
757mod tests {
758    use super::*;
759    use crate::test_workbook::TestWorkbook;
760    use crate::traits::ArgumentHandle;
761    use formualizer_parse::parser::{ASTNode, ASTNodeType};
762    use std::sync::Arc;
763
764    fn lit(v: LiteralValue) -> ASTNode {
765        ASTNode::new(ASTNodeType::Literal(v), None)
766    }
767
768    fn interp(wb: &TestWorkbook) -> crate::interpreter::Interpreter<'_> {
769        wb.interpreter()
770    }
771
772    #[test]
773    fn test_valuetotext_concise() {
774        let wb = TestWorkbook::new().with_function(Arc::new(ValueToTextFn));
775        let ctx = interp(&wb);
776        let f = ctx.context.get_function("", "VALUETOTEXT").unwrap();
777
778        // Test number
779        let num = lit(LiteralValue::Number(123.0));
780        let args = vec![ArgumentHandle::new(&num, &ctx)];
781        match f
782            .dispatch(&args, &ctx.function_context(None))
783            .unwrap()
784            .into_literal()
785        {
786            LiteralValue::Text(s) => assert_eq!(s, "123"),
787            v => panic!("unexpected {v:?}"),
788        }
789
790        // Test text (concise = no quotes)
791        let text = lit(LiteralValue::Text("hello".to_string()));
792        let args = vec![ArgumentHandle::new(&text, &ctx)];
793        match f
794            .dispatch(&args, &ctx.function_context(None))
795            .unwrap()
796            .into_literal()
797        {
798            LiteralValue::Text(s) => assert_eq!(s, "hello"),
799            v => panic!("unexpected {v:?}"),
800        }
801    }
802
803    #[test]
804    fn test_valuetotext_strict() {
805        let wb = TestWorkbook::new().with_function(Arc::new(ValueToTextFn));
806        let ctx = interp(&wb);
807        let f = ctx.context.get_function("", "VALUETOTEXT").unwrap();
808
809        // Test text with strict format (quotes)
810        let text = lit(LiteralValue::Text("hello".to_string()));
811        let format = lit(LiteralValue::Number(1.0));
812        let args = vec![
813            ArgumentHandle::new(&text, &ctx),
814            ArgumentHandle::new(&format, &ctx),
815        ];
816        match f
817            .dispatch(&args, &ctx.function_context(None))
818            .unwrap()
819            .into_literal()
820        {
821            LiteralValue::Text(s) => assert_eq!(s, "\"hello\""),
822            v => panic!("unexpected {v:?}"),
823        }
824    }
825
826    #[test]
827    fn test_arraytotext_concise() {
828        let wb = TestWorkbook::new().with_function(Arc::new(ArrayToTextFn));
829        let ctx = interp(&wb);
830        let f = ctx.context.get_function("", "ARRAYTOTEXT").unwrap();
831
832        // Test simple array
833        let arr = lit(LiteralValue::Array(vec![vec![
834            LiteralValue::Number(1.0),
835            LiteralValue::Number(2.0),
836            LiteralValue::Number(3.0),
837        ]]));
838        let args = vec![ArgumentHandle::new(&arr, &ctx)];
839        match f
840            .dispatch(&args, &ctx.function_context(None))
841            .unwrap()
842            .into_literal()
843        {
844            LiteralValue::Text(s) => assert_eq!(s, "1, 2, 3"),
845            v => panic!("unexpected {v:?}"),
846        }
847    }
848
849    #[test]
850    fn test_arraytotext_strict() {
851        let wb = TestWorkbook::new().with_function(Arc::new(ArrayToTextFn));
852        let ctx = interp(&wb);
853        let f = ctx.context.get_function("", "ARRAYTOTEXT").unwrap();
854
855        // Test 2D array with strict format
856        let arr = lit(LiteralValue::Array(vec![
857            vec![LiteralValue::Number(1.0), LiteralValue::Number(2.0)],
858            vec![LiteralValue::Number(3.0), LiteralValue::Number(4.0)],
859        ]));
860        let format = lit(LiteralValue::Number(1.0));
861        let args = vec![
862            ArgumentHandle::new(&arr, &ctx),
863            ArgumentHandle::new(&format, &ctx),
864        ];
865        match f
866            .dispatch(&args, &ctx.function_context(None))
867            .unwrap()
868            .into_literal()
869        {
870            LiteralValue::Text(s) => assert_eq!(s, "{1,2;3,4}"),
871            v => panic!("unexpected {v:?}"),
872        }
873    }
874
875    #[test]
876    fn test_textsplit_basic() {
877        let wb = TestWorkbook::new().with_function(Arc::new(TextSplitFn));
878        let ctx = interp(&wb);
879        let f = ctx.context.get_function("", "TEXTSPLIT").unwrap();
880
881        // Test simple split
882        let text = lit(LiteralValue::Text("a,b,c".to_string()));
883        let delim = lit(LiteralValue::Text(",".to_string()));
884        let args = vec![
885            ArgumentHandle::new(&text, &ctx),
886            ArgumentHandle::new(&delim, &ctx),
887        ];
888        match f
889            .dispatch(&args, &ctx.function_context(None))
890            .unwrap()
891            .into_literal()
892        {
893            LiteralValue::Array(arr) => {
894                assert_eq!(arr.len(), 1);
895                assert_eq!(arr[0].len(), 3);
896                assert_eq!(arr[0][0], LiteralValue::Text("a".to_string()));
897                assert_eq!(arr[0][1], LiteralValue::Text("b".to_string()));
898                assert_eq!(arr[0][2], LiteralValue::Text("c".to_string()));
899            }
900            v => panic!("unexpected {v:?}"),
901        }
902    }
903
904    #[test]
905    fn test_textsplit_2d() {
906        let wb = TestWorkbook::new().with_function(Arc::new(TextSplitFn));
907        let ctx = interp(&wb);
908        let f = ctx.context.get_function("", "TEXTSPLIT").unwrap();
909
910        // Test 2D split with row and column delimiters
911        let text = lit(LiteralValue::Text("a,b;c,d".to_string()));
912        let col_delim = lit(LiteralValue::Text(",".to_string()));
913        let row_delim = lit(LiteralValue::Text(";".to_string()));
914        let args = vec![
915            ArgumentHandle::new(&text, &ctx),
916            ArgumentHandle::new(&col_delim, &ctx),
917            ArgumentHandle::new(&row_delim, &ctx),
918        ];
919        match f
920            .dispatch(&args, &ctx.function_context(None))
921            .unwrap()
922            .into_literal()
923        {
924            LiteralValue::Array(arr) => {
925                assert_eq!(arr.len(), 2);
926                assert_eq!(arr[0].len(), 2);
927                assert_eq!(arr[0][0], LiteralValue::Text("a".to_string()));
928                assert_eq!(arr[0][1], LiteralValue::Text("b".to_string()));
929                assert_eq!(arr[1][0], LiteralValue::Text("c".to_string()));
930                assert_eq!(arr[1][1], LiteralValue::Text("d".to_string()));
931            }
932            v => panic!("unexpected {v:?}"),
933        }
934    }
935
936    #[test]
937    fn test_textsplit_ignore_empty() {
938        let wb = TestWorkbook::new().with_function(Arc::new(TextSplitFn));
939        let ctx = interp(&wb);
940        let f = ctx.context.get_function("", "TEXTSPLIT").unwrap();
941
942        // Test with consecutive delimiters and ignore_empty=TRUE
943        let text = lit(LiteralValue::Text("a,,b".to_string()));
944        let delim = lit(LiteralValue::Text(",".to_string()));
945        let row_delim = lit(LiteralValue::Empty);
946        let ignore_empty = lit(LiteralValue::Boolean(true));
947        let args = vec![
948            ArgumentHandle::new(&text, &ctx),
949            ArgumentHandle::new(&delim, &ctx),
950            ArgumentHandle::new(&row_delim, &ctx),
951            ArgumentHandle::new(&ignore_empty, &ctx),
952        ];
953        match f
954            .dispatch(&args, &ctx.function_context(None))
955            .unwrap()
956            .into_literal()
957        {
958            LiteralValue::Array(arr) => {
959                assert_eq!(arr.len(), 1);
960                assert_eq!(arr[0].len(), 2);
961                assert_eq!(arr[0][0], LiteralValue::Text("a".to_string()));
962                assert_eq!(arr[0][1], LiteralValue::Text("b".to_string()));
963            }
964            v => panic!("unexpected {v:?}"),
965        }
966    }
967
968    #[test]
969    fn test_textsplit_case_insensitive() {
970        let wb = TestWorkbook::new().with_function(Arc::new(TextSplitFn));
971        let ctx = interp(&wb);
972        let f = ctx.context.get_function("", "TEXTSPLIT").unwrap();
973
974        // Test case-insensitive matching
975        let text = lit(LiteralValue::Text("aXbxc".to_string()));
976        let delim = lit(LiteralValue::Text("X".to_string()));
977        let row_delim = lit(LiteralValue::Empty);
978        let ignore_empty = lit(LiteralValue::Boolean(false));
979        let match_mode = lit(LiteralValue::Number(1.0)); // case-insensitive
980        let args = vec![
981            ArgumentHandle::new(&text, &ctx),
982            ArgumentHandle::new(&delim, &ctx),
983            ArgumentHandle::new(&row_delim, &ctx),
984            ArgumentHandle::new(&ignore_empty, &ctx),
985            ArgumentHandle::new(&match_mode, &ctx),
986        ];
987        match f
988            .dispatch(&args, &ctx.function_context(None))
989            .unwrap()
990            .into_literal()
991        {
992            LiteralValue::Array(arr) => {
993                assert_eq!(arr.len(), 1);
994                assert_eq!(arr[0].len(), 3);
995                assert_eq!(arr[0][0], LiteralValue::Text("a".to_string()));
996                assert_eq!(arr[0][1], LiteralValue::Text("b".to_string()));
997                assert_eq!(arr[0][2], LiteralValue::Text("c".to_string()));
998            }
999            v => panic!("unexpected {v:?}"),
1000        }
1001    }
1002}