Skip to main content

runmat_runtime/builtins/strings/core/
string.rs

1//! MATLAB-compatible `string` builtin with GPU-aware conversion semantics for RunMat.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CharArray, ComplexTensor, IntValue, LogicalArray, SparseTensor, StringArray, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::format::{complex_to_string, format_variadic, number_to_string};
11use crate::builtins::common::map_control_flow_with_builtin;
12use crate::builtins::common::spec::{
13    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
14    ReductionNaN, ResidencyPolicy, ShapeRequirements,
15};
16use crate::builtins::common::tensor;
17use crate::builtins::strings::type_resolvers::string_array_type;
18use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
19
20const STRING_OUTPUT_S: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
21    name: "S",
22    ty: BuiltinParamType::Any,
23    arity: BuiltinParamArity::Required,
24    default: None,
25    description: "String scalar/array result.",
26}];
27
28const STRING_INPUTS_VALUE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
29    name: "X",
30    ty: BuiltinParamType::Any,
31    arity: BuiltinParamArity::Required,
32    default: None,
33    description: "Input value to convert to string array.",
34}];
35
36const STRING_INPUTS_VALUE_ENCODING: [BuiltinParamDescriptor; 2] = [
37    BuiltinParamDescriptor {
38        name: "X",
39        ty: BuiltinParamType::Any,
40        arity: BuiltinParamArity::Required,
41        default: None,
42        description: "Input value to convert to string array.",
43    },
44    BuiltinParamDescriptor {
45        name: "encoding",
46        ty: BuiltinParamType::StringScalar,
47        arity: BuiltinParamArity::Optional,
48        default: Some("\"UTF-8\""),
49        description: "Character encoding (UTF-8 aliases supported).",
50    },
51];
52
53const STRING_INPUTS_FORMAT: [BuiltinParamDescriptor; 2] = [
54    BuiltinParamDescriptor {
55        name: "formatSpec",
56        ty: BuiltinParamType::Any,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "Format specification text/cell/string array.",
60    },
61    BuiltinParamDescriptor {
62        name: "A",
63        ty: BuiltinParamType::Any,
64        arity: BuiltinParamArity::Variadic,
65        default: None,
66        description: "Formatting data arguments.",
67    },
68];
69
70const STRING_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
71    BuiltinSignatureDescriptor {
72        label: "S = string(X)",
73        inputs: &STRING_INPUTS_VALUE,
74        outputs: &STRING_OUTPUT_S,
75    },
76    BuiltinSignatureDescriptor {
77        label: "S = string(X, encoding)",
78        inputs: &STRING_INPUTS_VALUE_ENCODING,
79        outputs: &STRING_OUTPUT_S,
80    },
81    BuiltinSignatureDescriptor {
82        label: "S = string(formatSpec, A...)",
83        inputs: &STRING_INPUTS_FORMAT,
84        outputs: &STRING_OUTPUT_S,
85    },
86];
87
88const STRING_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
89    code: "RM.STRING.INVALID_INPUT",
90    identifier: Some("RunMat:string:InvalidInput"),
91    when: "Input conversion/formatting/encoding constraints are violated.",
92    message: "string: invalid input",
93};
94
95const STRING_ERRORS: [BuiltinErrorDescriptor; 1] = [STRING_ERROR_INVALID_INPUT];
96const STRING_SPARSE_DENSE_ELEMENT_LIMIT: usize = 10_000_000;
97
98pub const STRING_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
99    signatures: &STRING_SIGNATURES,
100    output_mode: BuiltinOutputMode::Fixed,
101    completion_policy: BuiltinCompletionPolicy::Public,
102    errors: &STRING_ERRORS,
103};
104
105#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::string")]
106pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
107    name: "string",
108    op_kind: GpuOpKind::Custom("conversion"),
109    supported_precisions: &[],
110    broadcast: BroadcastSemantics::None,
111    provider_hooks: &[],
112    constant_strategy: ConstantStrategy::InlineLiteral,
113    residency: ResidencyPolicy::GatherImmediately,
114    nan_mode: ReductionNaN::Include,
115    two_pass_threshold: None,
116    workgroup_size: None,
117    accepts_nan_mode: false,
118    notes: "Always converts on the CPU; GPU tensors are gathered to host memory before conversion.",
119};
120
121#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::string")]
122pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
123    name: "string",
124    shape: ShapeRequirements::Any,
125    constant_strategy: ConstantStrategy::InlineLiteral,
126    elementwise: None,
127    reduction: None,
128    emits_nan: false,
129    notes:
130        "Conversion builtin; not eligible for fusion and always materialises host string arrays.",
131};
132
133#[runtime_builtin(
134    name = "string",
135    category = "strings/core",
136    summary = "Convert numeric, logical, and text inputs into string arrays.",
137    keywords = "string,convert,text,char,gpu",
138    accel = "sink",
139    type_resolver(string_array_type),
140    descriptor(crate::builtins::strings::core::string::STRING_DESCRIPTOR),
141    builtin_path = "crate::builtins::strings::core::string"
142)]
143async fn string_builtin(value: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
144    if rest.is_empty() {
145        let gathered = gather_if_needed_async(&value)
146            .await
147            .map_err(|flow| remap_string_flow(flow))?;
148        let array = convert_to_string_array(gathered, StringEncoding::Utf8).await?;
149        return Ok(Value::StringArray(array));
150    }
151
152    let mut args = rest;
153    let format_value = gather_if_needed_async(&value)
154        .await
155        .map_err(|flow| remap_string_flow(flow))?;
156
157    if args.len() == 1 {
158        let arg = args.pop().unwrap();
159        let gathered_arg = gather_if_needed_async(&arg)
160            .await
161            .map_err(|flow| remap_string_flow(flow))?;
162        if let Some(encoding) = try_encoding_argument(&format_value, &gathered_arg)? {
163            let array = convert_to_string_array(format_value, encoding).await?;
164            return Ok(Value::StringArray(array));
165        }
166        let formatted = format_from_spec(format_value, vec![gathered_arg]).await?;
167        return Ok(Value::StringArray(formatted));
168    }
169
170    let mut gathered_args = Vec::with_capacity(args.len());
171    for arg in args {
172        gathered_args.push(
173            gather_if_needed_async(&arg)
174                .await
175                .map_err(|flow| remap_string_flow(flow))?,
176        );
177    }
178    let formatted = format_from_spec(format_value, gathered_args).await?;
179    Ok(Value::StringArray(formatted))
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
183enum StringEncoding {
184    Utf8,
185}
186
187fn try_encoding_argument(
188    first: &Value,
189    candidate: &Value,
190) -> BuiltinResult<Option<StringEncoding>> {
191    if !matches!(
192        first,
193        Value::CharArray(_) | Value::String(_) | Value::StringArray(_) | Value::Cell(_)
194    ) {
195        return Ok(None);
196    }
197    if has_format_placeholders(first) {
198        return Ok(None);
199    }
200    if let Value::Cell(cell) = first {
201        if !cell_contains_only_text_scalars(cell) {
202            return Ok(None);
203        }
204    }
205    let Some(text) = value_to_scalar_text(candidate) else {
206        return Ok(None);
207    };
208    parse_encoding_text(&text).map(Some)
209}
210
211fn parse_encoding_text(raw: &str) -> BuiltinResult<StringEncoding> {
212    let trimmed = raw.trim();
213    let lowered = trimmed.to_ascii_lowercase();
214    match lowered.as_str() {
215        "utf-8" | "utf8" | "unicode" | "system" => Ok(StringEncoding::Utf8),
216        _ => Err(string_flow(format!(
217            "string: unsupported character encoding '{trimmed}'; only UTF-8 is available"
218        ))),
219    }
220}
221
222fn cell_contains_only_text_scalars(cell: &runmat_builtins::CellArray) -> bool {
223    cell.data.iter().all(|ptr| match &**ptr {
224        Value::String(_) => true,
225        Value::StringArray(sa) => sa.data.len() <= 1,
226        Value::CharArray(ca) => ca.rows <= 1,
227        _ => false,
228    })
229}
230
231fn text_has_format_placeholder(text: &str) -> bool {
232    let mut chars = text.chars().peekable();
233    while let Some(ch) = chars.next() {
234        if ch != '%' {
235            continue;
236        }
237        if let Some('%') = chars.peek() {
238            chars.next();
239            continue;
240        }
241        while matches!(chars.peek(), Some(flag) if matches!(flag, '+' | '-' | '0' | '#')) {
242            chars.next();
243        }
244        while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
245            chars.next();
246        }
247        if let Some('.') = chars.peek() {
248            chars.next();
249            while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
250                chars.next();
251            }
252        }
253        if let Some(conv) = chars.peek() {
254            if conv.is_ascii_alphabetic() {
255                return true;
256            }
257        }
258    }
259    false
260}
261
262fn has_format_placeholders(value: &Value) -> bool {
263    match value {
264        Value::String(s) => text_has_format_placeholder(s),
265        Value::StringArray(sa) => sa.data.iter().any(|s| text_has_format_placeholder(s)),
266        Value::CharArray(ca) => {
267            for row in 0..ca.rows {
268                let mut row_str = String::with_capacity(ca.cols);
269                for col in 0..ca.cols {
270                    row_str.push(ca.data[row * ca.cols + col]);
271                }
272                if text_has_format_placeholder(&row_str) {
273                    return true;
274                }
275            }
276            false
277        }
278        Value::Cell(cell) => {
279            for ptr in &cell.data {
280                let element = (**ptr).clone();
281                if has_format_placeholders(&element) {
282                    return true;
283                }
284            }
285            false
286        }
287        _ => false,
288    }
289}
290
291pub(crate) struct FormatSpecData {
292    pub(crate) specs: Vec<String>,
293    pub(crate) shape: Vec<usize>,
294}
295
296struct ArgumentData {
297    values: Vec<Value>,
298    shape: Vec<usize>,
299}
300
301fn string_flow(message: impl Into<String>) -> RuntimeError {
302    string_error_with_detail(&STRING_ERROR_INVALID_INPUT, message)
303}
304
305fn string_error_with_detail(
306    error: &'static BuiltinErrorDescriptor,
307    detail: impl Into<String>,
308) -> RuntimeError {
309    let detail = detail.into();
310    let message = if detail.starts_with("string:") {
311        detail
312    } else {
313        format!("{}: {detail}", error.message)
314    };
315    let mut builder = build_runtime_error(message).with_builtin("string");
316    if let Some(identifier) = error.identifier {
317        builder = builder.with_identifier(identifier);
318    }
319    builder.build()
320}
321
322fn remap_string_flow(err: RuntimeError) -> RuntimeError {
323    map_control_flow_with_builtin(err, "string")
324}
325
326pub(crate) async fn format_from_spec(
327    format_value: Value,
328    args: Vec<Value>,
329) -> crate::BuiltinResult<StringArray> {
330    let spec = extract_format_spec(format_value).await?;
331    let mut arguments = Vec::with_capacity(args.len());
332    for arg in args {
333        arguments.push(extract_argument_data(arg).await?);
334    }
335
336    let (target_len, mut target_shape) = resolve_target_shape(&spec, &arguments)?;
337
338    if target_len == 0 {
339        let shape = if target_shape.is_empty() {
340            if spec.shape.is_empty() {
341                vec![0, 0]
342            } else {
343                spec.shape.clone()
344            }
345        } else {
346            target_shape
347        };
348        return StringArray::new(Vec::new(), shape)
349            .map_err(|e| string_flow(format!("string: {e}")));
350    }
351
352    let spec_len = spec.specs.len();
353    if spec_len == 0 {
354        return Err(string_flow(
355            "string: formatSpec must contain at least one element when formatting with data",
356        ));
357    }
358
359    for arg in &arguments {
360        if target_len > 0 && arg.values.is_empty() {
361            return Err(string_flow(
362                "string: format data arguments must be scalars or match formatSpec size",
363            ));
364        }
365    }
366
367    let mut output = Vec::with_capacity(target_len);
368    for idx in 0..target_len {
369        let spec_idx = if spec_len == 1 { 0 } else { idx };
370        let spec_str = &spec.specs[spec_idx];
371        let mut per_call = Vec::with_capacity(arguments.len());
372        for arg in &arguments {
373            let value =
374                match arg.values.len() {
375                    0 => continue,
376                    1 => arg.values[0].clone(),
377                    len if len == target_len => arg.values[idx].clone(),
378                    _ => return Err(string_flow(
379                        "string: format data arguments must be scalars or match formatSpec size",
380                    )),
381                };
382            per_call.push(value);
383        }
384        let formatted =
385            format_variadic(spec_str, &per_call).map_err(|flow| remap_string_flow(flow))?;
386        output.push(formatted);
387    }
388
389    if target_shape.is_empty() {
390        target_shape = if spec_len > 1 {
391            spec.shape.clone()
392        } else {
393            vec![target_len, 1]
394        };
395    }
396
397    if tensor::element_count(&target_shape) != target_len {
398        target_shape = vec![target_len, 1];
399    }
400
401    StringArray::new(output, target_shape).map_err(|e| string_flow(format!("string: {e}")))
402}
403
404fn resolve_target_shape(
405    spec: &FormatSpecData,
406    args: &[ArgumentData],
407) -> BuiltinResult<(usize, Vec<usize>)> {
408    let mut target_len = spec.specs.len();
409    let mut target_shape = if target_len > 1 || (target_len == 1 && !spec.shape.is_empty()) {
410        spec.shape.clone()
411    } else {
412        Vec::new()
413    };
414
415    for arg in args {
416        let len = arg.values.len();
417        if len == 0 {
418            continue;
419        }
420        if target_len == 0 {
421            target_len = len;
422            target_shape = arg.shape.clone();
423            continue;
424        }
425        if len == 1 {
426            continue;
427        }
428        if target_len == 1 {
429            target_len = len;
430            target_shape = arg.shape.clone();
431            continue;
432        }
433        if len != target_len {
434            return Err(string_flow(
435                "string: format data arguments must be scalars or match formatSpec size",
436            ));
437        }
438        if target_shape.is_empty() && len > 1 {
439            target_shape = arg.shape.clone();
440        }
441    }
442
443    if target_len == 0 {
444        let shape = if spec.shape.is_empty() {
445            vec![0, 0]
446        } else {
447            spec.shape.clone()
448        };
449        return Ok((0, shape));
450    }
451
452    if target_shape.is_empty() {
453        target_shape = if spec.shape.is_empty() {
454            vec![target_len, 1]
455        } else {
456            spec.shape.clone()
457        };
458        if spec.specs.len() == 1 && tensor::element_count(&target_shape) != target_len {
459            target_shape = vec![target_len, 1];
460        }
461    }
462
463    if tensor::element_count(&target_shape) != target_len {
464        target_shape = vec![target_len, 1];
465    }
466
467    Ok((target_len, target_shape))
468}
469
470pub(crate) async fn extract_format_spec(value: Value) -> BuiltinResult<FormatSpecData> {
471    match value {
472        Value::String(s) => Ok(FormatSpecData {
473            specs: vec![s],
474            shape: vec![1, 1],
475        }),
476        Value::StringArray(sa) => Ok(FormatSpecData {
477            specs: sa.data.clone(),
478            shape: sa.shape.clone(),
479        }),
480        Value::CharArray(ca) => {
481            let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
482            Ok(FormatSpecData {
483                specs: array.data,
484                shape: array.shape,
485            })
486        }
487        Value::Cell(cell) => {
488            let mut specs = Vec::with_capacity(cell.data.len());
489            for col in 0..cell.cols {
490                for row in 0..cell.rows {
491                    let idx = row * cell.cols + col;
492                    let element = &cell.data[idx];
493                    let value = (**element).clone();
494                    let gathered = gather_if_needed_async(&value)
495                        .await
496                        .map_err(|flow| remap_string_flow(flow))?;
497                    let text = value_to_scalar_text(&gathered).ok_or_else(|| {
498                        string_flow("string: formatSpec cell elements must be text scalars")
499                    })?;
500                    specs.push(text);
501                }
502            }
503            Ok(FormatSpecData {
504                specs,
505                shape: vec![cell.rows, cell.cols],
506            })
507        }
508        _ => Err(string_flow(
509            "string: formatSpec must be text (string, char, or cellstr)",
510        )),
511    }
512}
513
514#[async_recursion::async_recursion(?Send)]
515async fn extract_argument_data(value: Value) -> BuiltinResult<ArgumentData> {
516    match value {
517        Value::String(s) => Ok(ArgumentData {
518            values: vec![Value::String(s)],
519            shape: vec![1, 1],
520        }),
521        Value::StringArray(sa) => Ok(ArgumentData {
522            values: sa.data.into_iter().map(Value::String).collect(),
523            shape: sa.shape,
524        }),
525        Value::CharArray(ca) => {
526            let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
527            Ok(ArgumentData {
528                values: array.data.into_iter().map(Value::String).collect(),
529                shape: array.shape,
530            })
531        }
532        Value::Num(n) => Ok(ArgumentData {
533            values: vec![Value::Num(n)],
534            shape: vec![1, 1],
535        }),
536        Value::Int(i) => Ok(ArgumentData {
537            values: vec![Value::Int(i)],
538            shape: vec![1, 1],
539        }),
540        Value::Bool(b) => Ok(ArgumentData {
541            values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
542            shape: vec![1, 1],
543        }),
544        Value::Tensor(t) => Ok(ArgumentData {
545            values: t.data.into_iter().map(Value::Num).collect(),
546            shape: t.shape,
547        }),
548        Value::SparseTensor(s) => {
549            ensure_sparse_dense_conversion(&s, "format argument")?;
550            let dense = s.to_dense().map_err(string_flow)?;
551            Ok(ArgumentData {
552                values: dense.data.into_iter().map(Value::Num).collect(),
553                shape: dense.shape,
554            })
555        }
556        Value::Complex(re, im) => Ok(ArgumentData {
557            values: vec![Value::String(complex_to_string(re, im))],
558            shape: vec![1, 1],
559        }),
560        Value::ComplexTensor(t) => Ok(ArgumentData {
561            values: t
562                .data
563                .into_iter()
564                .map(|(re, im)| Value::String(complex_to_string(re, im)))
565                .collect(),
566            shape: t.shape,
567        }),
568        Value::LogicalArray(la) => Ok(ArgumentData {
569            values: la
570                .data
571                .into_iter()
572                .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
573                .collect(),
574            shape: la.shape,
575        }),
576        Value::Cell(cell) => {
577            let mut values = Vec::with_capacity(cell.data.len());
578            for col in 0..cell.cols {
579                for row in 0..cell.rows {
580                    let idx = row * cell.cols + col;
581                    let element = &cell.data[idx];
582                    let value = (**element).clone();
583                    let gathered = gather_if_needed_async(&value)
584                        .await
585                        .map_err(|flow| remap_string_flow(flow))?;
586                    let value = match gathered {
587                        Value::String(s) => Value::String(s),
588                        Value::StringArray(sa) if sa.data.len() == 1 => {
589                            Value::String(sa.data[0].clone())
590                        }
591                        Value::CharArray(ca) => {
592                            if ca.rows != 1 {
593                                return Err(string_flow(
594                                    "string: cell format arguments must contain char row vectors",
595                                ));
596                            }
597                            let mut row_str = String::with_capacity(ca.cols);
598                            for ch in ca.data {
599                                row_str.push(ch);
600                            }
601                            Value::String(row_str)
602                        }
603                        Value::Num(n) => Value::Num(n),
604                        Value::Int(i) => Value::Int(i),
605                        Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
606                        Value::Tensor(t) => {
607                            if t.data.len() != 1 {
608                                return Err(string_flow(
609                                    "string: cell format arguments must contain scalar values",
610                                ));
611                            }
612                            Value::Num(t.data[0])
613                        }
614                        Value::LogicalArray(la) => {
615                            if la.data.len() != 1 {
616                                return Err(string_flow(
617                                    "string: cell format arguments must contain scalar values",
618                                ));
619                            }
620                            Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
621                        }
622                        Value::Complex(re, im) => Value::String(complex_to_string(re, im)),
623                        Value::ComplexTensor(t) => {
624                            if t.data.len() != 1 {
625                                return Err(string_flow(
626                                    "string: cell format arguments must contain scalar values",
627                                ));
628                            }
629                            let (re, im) = t.data[0];
630                            Value::String(complex_to_string(re, im))
631                        }
632                        other => {
633                            return Err(string_flow(format!(
634                                "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
635                            )))
636                        }
637                    };
638                    values.push(value);
639                }
640            }
641            Ok(ArgumentData {
642                values,
643                shape: vec![cell.rows, cell.cols],
644            })
645        }
646        Value::GpuTensor(handle) => {
647            let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
648                .await
649                .map_err(|flow| remap_string_flow(flow))?;
650            extract_argument_data(gathered).await
651        }
652        Value::MException(_)
653        | Value::HandleObject(_)
654        | Value::Object(_)
655        | Value::Listener(_)
656        | Value::Struct(_)
657        | Value::OutputList(_) => Err(string_flow("string: unsupported format argument type")),
658        Value::FunctionHandle(_)
659        | Value::ExternalFunctionHandle(_)
660        | Value::MethodFunctionHandle(_)
661        | Value::BoundFunctionHandle { .. }
662        | Value::Closure(_)
663        | Value::ClassRef(_) => Err(string_flow("string: unsupported format argument type")),
664    }
665}
666
667#[async_recursion::async_recursion(?Send)]
668async fn convert_to_string_array(
669    value: Value,
670    encoding: StringEncoding,
671) -> BuiltinResult<StringArray> {
672    if let Some(array) = crate::builtins::datetime::datetime_string_array(&value)
673        .map_err(|err| string_flow(err.message().to_string()))?
674    {
675        return Ok(array);
676    }
677    if let Some(array) = crate::builtins::duration::duration_string_array(&value)
678        .map_err(|err| string_flow(err.message().to_string()))?
679    {
680        return Ok(array);
681    }
682    match value {
683        Value::String(s) => string_scalar(s),
684        Value::StringArray(sa) => Ok(sa),
685        Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
686        Value::Tensor(tensor) => tensor_to_string_array(tensor),
687        Value::SparseTensor(sparse) => {
688            ensure_sparse_dense_conversion(&sparse, "dense string array")?;
689            tensor_to_string_array(sparse.to_dense().map_err(string_flow)?)
690        }
691        Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
692        Value::LogicalArray(logical) => logical_array_to_string_array(logical),
693        Value::Cell(cell) => cell_array_to_string_array(cell, encoding).await,
694        Value::Num(n) => string_scalar(number_to_string(n)),
695        Value::Int(i) => string_scalar(int_value_to_string(&i)),
696        Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
697        Value::Complex(re, im) => string_scalar(complex_to_string(re, im)),
698        Value::GpuTensor(handle) => {
699            // Defensive fallback: gather and retry.
700            let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
701                .await
702                .map_err(|flow| remap_string_flow(flow))?;
703            convert_to_string_array(gathered, encoding).await
704        }
705        Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(string_flow(
706            "string: unsupported conversion from handle-based objects. Use class-specific formatters.",
707        )),
708        Value::Struct(_) => Err(string_flow(
709            "string: structs are not supported for automatic conversion",
710        )),
711        Value::FunctionHandle(_) | Value::ExternalFunctionHandle(_) | Value::MethodFunctionHandle(_) | Value::BoundFunctionHandle { .. }
712        | Value::Closure(_)
713        | Value::ClassRef(_)
714        | Value::MException(_)
715        | Value::OutputList(_) => Err(
716            string_flow("string: unsupported conversion for function or exception handles"),
717        ),
718    }
719}
720
721fn string_scalar<S: Into<String>>(text: S) -> BuiltinResult<StringArray> {
722    StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| string_flow(format!("string: {e}")))
723}
724
725fn value_to_scalar_text(value: &Value) -> Option<String> {
726    match value {
727        Value::String(s) => Some(s.clone()),
728        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
729        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
730        _ => None,
731    }
732}
733
734fn char_array_to_string_array(
735    array: CharArray,
736    _encoding: StringEncoding,
737) -> BuiltinResult<StringArray> {
738    let mut rows: Vec<String> = Vec::with_capacity(array.rows);
739    for r in 0..array.rows {
740        let mut row = String::with_capacity(array.cols);
741        for c in 0..array.cols {
742            row.push(array.data[r * array.cols + c]);
743        }
744        rows.push(row);
745    }
746    let shape = if array.rows == 0 {
747        vec![0, 1]
748    } else {
749        vec![array.rows, 1]
750    };
751    StringArray::new(rows, shape).map_err(|e| string_flow(format!("string: {e}")))
752}
753
754fn tensor_to_string_array(tensor: Tensor) -> BuiltinResult<StringArray> {
755    let mut strings = Vec::with_capacity(tensor.data.len());
756    for &value in &tensor.data {
757        strings.push(number_to_string(value));
758    }
759    StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
760}
761
762fn complex_tensor_to_string_array(tensor: ComplexTensor) -> BuiltinResult<StringArray> {
763    let mut strings = Vec::with_capacity(tensor.data.len());
764    for &(re, im) in &tensor.data {
765        strings.push(complex_to_string(re, im));
766    }
767    StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
768}
769
770fn logical_array_to_string_array(logical: LogicalArray) -> BuiltinResult<StringArray> {
771    let mut strings = Vec::with_capacity(logical.data.len());
772    for &byte in &logical.data {
773        strings.push(bool_to_string(byte != 0).to_string());
774    }
775    StringArray::new(strings, logical.shape).map_err(|e| string_flow(format!("string: {e}")))
776}
777
778async fn cell_array_to_string_array(
779    cell: runmat_builtins::CellArray,
780    _encoding: StringEncoding,
781) -> BuiltinResult<StringArray> {
782    let mut strings = Vec::with_capacity(cell.data.len());
783    for col in 0..cell.cols {
784        for row in 0..cell.rows {
785            let idx = row * cell.cols + col;
786            let element = &cell.data[idx];
787            let value = (**element).clone();
788            let gathered = gather_if_needed_async(&value)
789                .await
790                .map_err(|flow| remap_string_flow(flow))?;
791            strings.push(cell_element_to_string(&gathered)?);
792        }
793    }
794    StringArray::new(strings, vec![cell.rows, cell.cols])
795        .map_err(|e| string_flow(format!("string: {e}")))
796}
797
798fn cell_element_to_string(value: &Value) -> BuiltinResult<String> {
799    if let Some(array) = crate::builtins::datetime::datetime_string_array(value)
800        .map_err(|err| string_flow(err.message().to_string()))?
801    {
802        if array.data.len() == 1 {
803            return Ok(array.data[0].clone());
804        }
805        return Err(string_flow("string: cell datetime values must be scalar"));
806    }
807    if let Some(array) = crate::builtins::duration::duration_string_array(value)
808        .map_err(|err| string_flow(err.message().to_string()))?
809    {
810        if array.data.len() == 1 {
811            return Ok(array.data[0].clone());
812        }
813        return Err(string_flow("string: cell duration values must be scalar"));
814    }
815    match value {
816        Value::String(s) => Ok(s.clone()),
817        Value::StringArray(sa) => {
818            if sa.data.len() == 1 {
819                Ok(sa.data[0].clone())
820            } else {
821                Err(string_flow(
822                    "string: cell elements must contain string scalars, not string arrays",
823                ))
824            }
825        }
826        Value::CharArray(ca) => {
827            if ca.rows == 1 {
828                Ok(ca.data.iter().collect())
829            } else {
830                Err(string_flow(
831                    "string: cell character arrays must be row vectors",
832                ))
833            }
834        }
835        Value::Num(n) => Ok(number_to_string(*n)),
836        Value::Int(i) => Ok(int_value_to_string(i)),
837        Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
838        Value::LogicalArray(array) => {
839            if array.data.len() == 1 {
840                Ok(bool_to_string(array.data[0] != 0).to_string())
841            } else {
842                Err(string_flow("string: cell logical values must be scalar"))
843            }
844        }
845        Value::Tensor(t) => {
846            if t.data.len() == 1 {
847                Ok(number_to_string(t.data[0]))
848            } else {
849                Err(string_flow("string: cell numeric values must be scalar"))
850            }
851        }
852        Value::Complex(re, im) => Ok(complex_to_string(*re, *im)),
853        Value::ComplexTensor(t) => {
854            if t.data.len() == 1 {
855                let (re, im) = t.data[0];
856                Ok(complex_to_string(re, im))
857            } else {
858                Err(string_flow("string: cell complex values must be scalar"))
859            }
860        }
861        other => Err(string_flow(format!(
862            "string: unsupported cell element type {:?}; expected text or scalar values",
863            other
864        ))),
865    }
866}
867
868fn ensure_sparse_dense_conversion(sparse: &SparseTensor, target: &str) -> BuiltinResult<()> {
869    let total_elements = sparse
870        .rows
871        .checked_mul(sparse.cols)
872        .ok_or_else(|| string_flow("string: sparse matrix dimensions overflow"))?;
873    if total_elements > STRING_SPARSE_DENSE_ELEMENT_LIMIT {
874        return Err(string_flow(format!(
875            "string: cannot convert sparse tensor {}x{} with {} stored entries to {target} ({} elements exceeds safe threshold)",
876            sparse.rows,
877            sparse.cols,
878            sparse.nnz(),
879            total_elements
880        )));
881    }
882    Ok(())
883}
884
885fn bool_to_string(value: bool) -> &'static str {
886    if value {
887        "true"
888    } else {
889        "false"
890    }
891}
892
893fn int_value_to_string(value: &IntValue) -> String {
894    match value {
895        IntValue::I8(v) => v.to_string(),
896        IntValue::I16(v) => v.to_string(),
897        IntValue::I32(v) => v.to_string(),
898        IntValue::I64(v) => v.to_string(),
899        IntValue::U8(v) => v.to_string(),
900        IntValue::U16(v) => v.to_string(),
901        IntValue::U32(v) => v.to_string(),
902        IntValue::U64(v) => v.to_string(),
903    }
904}
905
906#[cfg(test)]
907pub(crate) mod tests {
908    use super::*;
909    use crate::builtins::common::test_support;
910    use runmat_builtins::{CellArray, IntValue, ResolveContext, StringArray, StructValue, Type};
911
912    fn string_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
913        futures::executor::block_on(super::string_builtin(value, rest))
914    }
915
916    fn error_message(err: crate::RuntimeError) -> String {
917        err.message().to_string()
918    }
919
920    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
921    #[test]
922    fn string_from_numeric_scalar() {
923        let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
924        match out {
925            Value::StringArray(sa) => {
926                assert_eq!(sa.shape, vec![1, 1]);
927                assert_eq!(sa.data, vec!["42".to_string()]);
928            }
929            other => panic!("expected string array, got {other:?}"),
930        }
931    }
932
933    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
934    #[test]
935    fn string_from_numeric_tensor_preserves_shape() {
936        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
937        let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
938        match out {
939            Value::StringArray(sa) => {
940                assert_eq!(sa.shape, vec![2, 2]);
941                assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
942            }
943            other => panic!("expected string array, got {other:?}"),
944        }
945    }
946
947    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
948    #[test]
949    fn string_from_logical_array_uses_boolean_text() {
950        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
951        let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
952        match out {
953            Value::StringArray(sa) => {
954                assert_eq!(sa.shape, vec![1, 3]);
955                assert_eq!(sa.data, vec!["true", "false", "true"]);
956            }
957            other => panic!("expected string array, got {other:?}"),
958        }
959    }
960
961    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
962    #[test]
963    fn string_from_char_array_produces_column_vector() {
964        let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
965        let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
966        match out {
967            Value::StringArray(sa) => {
968                assert_eq!(sa.shape, vec![1, 1]);
969                assert_eq!(sa.data, vec!["abc"]);
970            }
971            other => panic!("expected string array, got {other:?}"),
972        }
973    }
974
975    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
976    #[test]
977    fn string_from_cell_array() {
978        let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
979            .expect("cell array");
980        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
981        match out {
982            Value::StringArray(sa) => {
983                assert_eq!(sa.shape, vec![1, 2]);
984                assert_eq!(sa.data, vec!["true", "7"]);
985            }
986            other => panic!("expected string array, got {other:?}"),
987        }
988    }
989
990    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
991    #[test]
992    fn string_from_cell_array_column_major() {
993        let cell = CellArray::new(
994            vec![
995                Value::Int(IntValue::I32(1)),
996                Value::Int(IntValue::I32(2)),
997                Value::Int(IntValue::I32(3)),
998                Value::Int(IntValue::I32(4)),
999            ],
1000            2,
1001            2,
1002        )
1003        .expect("cell array");
1004        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1005        match out {
1006            Value::StringArray(sa) => {
1007                assert_eq!(sa.shape, vec![2, 2]);
1008                assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
1009            }
1010            other => panic!("expected string array, got {other:?}"),
1011        }
1012    }
1013
1014    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1015    #[test]
1016    fn string_cell_element_requires_scalar_numeric() {
1017        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1018        let cell =
1019            CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
1020        let err = error_message(string_builtin(Value::Cell(cell), Vec::new()).unwrap_err());
1021        assert!(err.contains("cell numeric values must be scalar"));
1022    }
1023
1024    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1025    #[test]
1026    fn string_rejects_struct_input() {
1027        let err = error_message(
1028            string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string"),
1029        );
1030        assert!(err.contains("structs are not supported"));
1031    }
1032
1033    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1034    #[test]
1035    fn string_errors_on_unsupported_encoding() {
1036        let err = error_message(
1037            string_builtin(
1038                Value::CharArray(CharArray::new_row("abc")),
1039                vec![Value::from("UTF-16")],
1040            )
1041            .unwrap_err(),
1042        );
1043        assert!(
1044            err.contains("unsupported character encoding"),
1045            "unexpected error message: {err}"
1046        );
1047    }
1048
1049    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1050    #[test]
1051    fn string_accepts_system_encoding_alias() {
1052        let out = string_builtin(
1053            Value::CharArray(CharArray::new_row("hello")),
1054            vec![Value::from("system")],
1055        )
1056        .expect("string");
1057        match out {
1058            Value::StringArray(sa) => {
1059                assert_eq!(sa.shape, vec![1, 1]);
1060                assert_eq!(sa.data, vec!["hello"]);
1061            }
1062            other => panic!("expected string array, got {other:?}"),
1063        }
1064    }
1065
1066    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1067    #[test]
1068    fn string_encoding_allows_percent_literal() {
1069        let out = string_builtin(
1070            Value::CharArray(CharArray::new_row("100% Done")),
1071            vec![Value::from("utf8")],
1072        )
1073        .expect("string");
1074        match out {
1075            Value::StringArray(sa) => {
1076                assert_eq!(sa.shape, vec![1, 1]);
1077                assert_eq!(sa.data, vec!["100% Done"]);
1078            }
1079            other => panic!("expected string array, got {other:?}"),
1080        }
1081    }
1082
1083    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1084    #[test]
1085    fn string_format_spec_cell_requires_text_scalars() {
1086        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1087        let err = error_message(
1088            string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string"),
1089        );
1090        assert!(
1091            err.contains("formatSpec cell elements must be text scalars"),
1092            "unexpected error: {err}"
1093        );
1094    }
1095
1096    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1097    #[test]
1098    fn string_format_cell_argument_requires_scalar_values() {
1099        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1100        let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1101        let err = error_message(
1102            string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string"),
1103        );
1104        assert!(err.contains("cell format arguments must contain scalar values"));
1105    }
1106
1107    #[test]
1108    fn string_rejects_oversized_sparse_tensor_before_densifying() {
1109        let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1110        let err = string_builtin(Value::SparseTensor(sparse), Vec::new()).unwrap_err();
1111
1112        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1113        assert!(err.message().contains("exceeds safe threshold"));
1114    }
1115
1116    #[test]
1117    fn string_format_rejects_oversized_sparse_argument_before_densifying() {
1118        let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1119        let err = string_builtin(Value::from("%g"), vec![Value::SparseTensor(sparse)]).unwrap_err();
1120
1121        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1122        assert!(err.message().contains("format argument"));
1123        assert!(err.message().contains("exceeds safe threshold"));
1124    }
1125
1126    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1127    #[test]
1128    fn string_handles_large_unsigned_int() {
1129        let value = Value::Int(IntValue::U64(u64::MAX));
1130        let out = string_builtin(value, Vec::new()).expect("string");
1131        match out {
1132            Value::StringArray(sa) => {
1133                assert_eq!(sa.shape, vec![1, 1]);
1134                assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1135            }
1136            other => panic!("expected string array, got {other:?}"),
1137        }
1138    }
1139
1140    #[test]
1141    fn string_descriptor_signatures_cover_core_forms() {
1142        let labels: Vec<&str> = STRING_DESCRIPTOR
1143            .signatures
1144            .iter()
1145            .map(|signature| signature.label)
1146            .collect();
1147        assert_eq!(
1148            labels,
1149            vec![
1150                "S = string(X)",
1151                "S = string(X, encoding)",
1152                "S = string(formatSpec, A...)",
1153            ]
1154        );
1155
1156        let codes: Vec<&str> = STRING_DESCRIPTOR
1157            .errors
1158            .iter()
1159            .map(|error| error.code)
1160            .collect();
1161        assert_eq!(codes, vec!["RM.STRING.INVALID_INPUT"]);
1162    }
1163
1164    #[test]
1165    fn string_struct_input_uses_stable_identifier() {
1166        let err = string_builtin(Value::Struct(StructValue::new()), Vec::new()).unwrap_err();
1167        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1168    }
1169
1170    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1171    #[test]
1172    fn string_format_numeric_scalar() {
1173        let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1174        match out {
1175            Value::StringArray(sa) => {
1176                assert_eq!(sa.shape, vec![1, 1]);
1177                assert_eq!(sa.data, vec!["7"]);
1178            }
1179            other => panic!("expected string array, got {other:?}"),
1180        }
1181    }
1182
1183    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1184    #[test]
1185    fn string_format_broadcast_over_tensor() {
1186        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1187        let out =
1188            string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1189        match out {
1190            Value::StringArray(sa) => {
1191                assert_eq!(sa.shape, vec![1, 3]);
1192                assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1193            }
1194            other => panic!("expected string array, got {other:?}"),
1195        }
1196    }
1197
1198    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1199    #[test]
1200    fn string_format_string_array_spec_alignment() {
1201        let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1202        let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1203        let out =
1204            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1205        match out {
1206            Value::StringArray(sa) => {
1207                assert_eq!(sa.shape, vec![1, 2]);
1208                assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1209            }
1210            other => panic!("expected string array, got {other:?}"),
1211        }
1212    }
1213
1214    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1215    #[test]
1216    fn string_format_prefers_placeholders_over_encoding_hint() {
1217        let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1218        match out {
1219            Value::StringArray(sa) => {
1220                assert_eq!(sa.shape, vec![1, 1]);
1221                assert_eq!(sa.data, vec!["UTF-8"]);
1222            }
1223            other => panic!("expected string array, got {other:?}"),
1224        }
1225    }
1226
1227    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1228    #[test]
1229    fn string_format_mismatched_lengths_errors() {
1230        let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1231        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1232        let err = error_message(
1233            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err(),
1234        );
1235        assert!(err.contains("must be scalars or match formatSpec size"));
1236    }
1237
1238    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1239    #[test]
1240    fn string_gpu_numeric_tensor() {
1241        test_support::with_test_provider(|provider| {
1242            let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1243            let view = runmat_accelerate_api::HostTensorView {
1244                data: &tensor.data,
1245                shape: &tensor.shape,
1246            };
1247            let handle = provider.upload(&view).expect("upload");
1248            let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1249                .expect("gpu string conversion");
1250            match result {
1251                Value::StringArray(sa) => {
1252                    assert_eq!(sa.shape, vec![1, 2]);
1253                    assert_eq!(sa.data, vec!["10", "20"]);
1254                }
1255                other => panic!("expected string array, got {other:?}"),
1256            }
1257        });
1258    }
1259
1260    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1261    #[test]
1262    #[cfg(feature = "wgpu")]
1263    fn string_wgpu_numeric_tensor_matches_cpu() {
1264        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1265            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1266        );
1267        let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1268        let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1269            .expect("cpu string conversion");
1270        let view = runmat_accelerate_api::HostTensorView {
1271            data: &tensor.data,
1272            shape: &tensor.shape,
1273        };
1274        let handle = runmat_accelerate_api::provider()
1275            .unwrap()
1276            .upload(&view)
1277            .expect("gpu upload");
1278        let gpu =
1279            string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1280        match (cpu, gpu) {
1281            (Value::StringArray(expect), Value::StringArray(actual)) => {
1282                assert_eq!(actual.shape, expect.shape);
1283                assert_eq!(actual.data, expect.data);
1284            }
1285            other => panic!("unexpected results {other:?}"),
1286        }
1287    }
1288
1289    #[test]
1290    fn string_type_is_string_array() {
1291        assert_eq!(
1292            string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1293            Type::cell_of(Type::String)
1294        );
1295    }
1296}