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::Symbolic(expr) => Ok(ArgumentData {
533            values: vec![Value::String(expr.to_string())],
534            shape: vec![1, 1],
535        }),
536        Value::Num(n) => Ok(ArgumentData {
537            values: vec![Value::Num(n)],
538            shape: vec![1, 1],
539        }),
540        Value::Int(i) => Ok(ArgumentData {
541            values: vec![Value::Int(i)],
542            shape: vec![1, 1],
543        }),
544        Value::Bool(b) => Ok(ArgumentData {
545            values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
546            shape: vec![1, 1],
547        }),
548        Value::Tensor(t) => Ok(ArgumentData {
549            values: t.data.into_iter().map(Value::Num).collect(),
550            shape: t.shape,
551        }),
552        Value::SparseTensor(s) => {
553            ensure_sparse_dense_conversion(&s, "format argument")?;
554            let dense = s.to_dense().map_err(string_flow)?;
555            Ok(ArgumentData {
556                values: dense.data.into_iter().map(Value::Num).collect(),
557                shape: dense.shape,
558            })
559        }
560        Value::Complex(re, im) => Ok(ArgumentData {
561            values: vec![Value::String(complex_to_string(re, im))],
562            shape: vec![1, 1],
563        }),
564        Value::ComplexTensor(t) => Ok(ArgumentData {
565            values: t
566                .data
567                .into_iter()
568                .map(|(re, im)| Value::String(complex_to_string(re, im)))
569                .collect(),
570            shape: t.shape,
571        }),
572        Value::LogicalArray(la) => Ok(ArgumentData {
573            values: la
574                .data
575                .into_iter()
576                .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
577                .collect(),
578            shape: la.shape,
579        }),
580        Value::Cell(cell) => {
581            let mut values = Vec::with_capacity(cell.data.len());
582            for col in 0..cell.cols {
583                for row in 0..cell.rows {
584                    let idx = row * cell.cols + col;
585                    let element = &cell.data[idx];
586                    let value = (**element).clone();
587                    let gathered = gather_if_needed_async(&value)
588                        .await
589                        .map_err(|flow| remap_string_flow(flow))?;
590                    let value = match gathered {
591                        Value::String(s) => Value::String(s),
592                        Value::StringArray(sa) if sa.data.len() == 1 => {
593                            Value::String(sa.data[0].clone())
594                        }
595                        Value::CharArray(ca) => {
596                            if ca.rows != 1 {
597                                return Err(string_flow(
598                                    "string: cell format arguments must contain char row vectors",
599                                ));
600                            }
601                            let mut row_str = String::with_capacity(ca.cols);
602                            for ch in ca.data {
603                                row_str.push(ch);
604                            }
605                            Value::String(row_str)
606                        }
607                        Value::Num(n) => Value::Num(n),
608                        Value::Int(i) => Value::Int(i),
609                        Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
610                        Value::Tensor(t) => {
611                            if t.data.len() != 1 {
612                                return Err(string_flow(
613                                    "string: cell format arguments must contain scalar values",
614                                ));
615                            }
616                            Value::Num(t.data[0])
617                        }
618                        Value::LogicalArray(la) => {
619                            if la.data.len() != 1 {
620                                return Err(string_flow(
621                                    "string: cell format arguments must contain scalar values",
622                                ));
623                            }
624                            Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
625                        }
626                        Value::Complex(re, im) => Value::String(complex_to_string(re, im)),
627                        Value::Symbolic(expr) => Value::String(expr.to_string()),
628                        Value::ComplexTensor(t) => {
629                            if t.data.len() != 1 {
630                                return Err(string_flow(
631                                    "string: cell format arguments must contain scalar values",
632                                ));
633                            }
634                            let (re, im) = t.data[0];
635                            Value::String(complex_to_string(re, im))
636                        }
637                        other => {
638                            return Err(string_flow(format!(
639                                "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
640                            )))
641                        }
642                    };
643                    values.push(value);
644                }
645            }
646            Ok(ArgumentData {
647                values,
648                shape: vec![cell.rows, cell.cols],
649            })
650        }
651        Value::GpuTensor(handle) => {
652            let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
653                .await
654                .map_err(|flow| remap_string_flow(flow))?;
655            extract_argument_data(gathered).await
656        }
657        Value::MException(_)
658        | Value::HandleObject(_)
659        | Value::Object(_)
660        | Value::Listener(_)
661        | Value::Struct(_)
662        | Value::OutputList(_) => Err(string_flow("string: unsupported format argument type")),
663        Value::FunctionHandle(_)
664        | Value::ExternalFunctionHandle(_)
665        | Value::MethodFunctionHandle(_)
666        | Value::BoundFunctionHandle { .. }
667        | Value::Closure(_)
668        | Value::ClassRef(_) => Err(string_flow("string: unsupported format argument type")),
669    }
670}
671
672#[async_recursion::async_recursion(?Send)]
673async fn convert_to_string_array(
674    value: Value,
675    encoding: StringEncoding,
676) -> BuiltinResult<StringArray> {
677    if let Some(array) = crate::builtins::datetime::datetime_string_array(&value)
678        .map_err(|err| string_flow(err.message().to_string()))?
679    {
680        return Ok(array);
681    }
682    if let Some(array) = crate::builtins::duration::duration_string_array(&value)
683        .map_err(|err| string_flow(err.message().to_string()))?
684    {
685        return Ok(array);
686    }
687    match value {
688        Value::String(s) => string_scalar(s),
689        Value::StringArray(sa) => Ok(sa),
690        Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
691        Value::Symbolic(expr) => string_scalar(expr.to_string()),
692        Value::Tensor(tensor) => tensor_to_string_array(tensor),
693        Value::SparseTensor(sparse) => {
694            ensure_sparse_dense_conversion(&sparse, "dense string array")?;
695            tensor_to_string_array(sparse.to_dense().map_err(string_flow)?)
696        }
697        Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
698        Value::LogicalArray(logical) => logical_array_to_string_array(logical),
699        Value::Cell(cell) => cell_array_to_string_array(cell, encoding).await,
700        Value::Num(n) => string_scalar(number_to_string(n)),
701        Value::Int(i) => string_scalar(int_value_to_string(&i)),
702        Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
703        Value::Complex(re, im) => string_scalar(complex_to_string(re, im)),
704        Value::GpuTensor(handle) => {
705            // Defensive fallback: gather and retry.
706            let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
707                .await
708                .map_err(|flow| remap_string_flow(flow))?;
709            convert_to_string_array(gathered, encoding).await
710        }
711        Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(string_flow(
712            "string: unsupported conversion from handle-based objects. Use class-specific formatters.",
713        )),
714        Value::Struct(_) => Err(string_flow(
715            "string: structs are not supported for automatic conversion",
716        )),
717        Value::FunctionHandle(_) | Value::ExternalFunctionHandle(_) | Value::MethodFunctionHandle(_) | Value::BoundFunctionHandle { .. }
718        | Value::Closure(_)
719        | Value::ClassRef(_)
720        | Value::MException(_)
721        | Value::OutputList(_) => Err(
722            string_flow("string: unsupported conversion for function or exception handles"),
723        ),
724    }
725}
726
727fn string_scalar<S: Into<String>>(text: S) -> BuiltinResult<StringArray> {
728    StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| string_flow(format!("string: {e}")))
729}
730
731fn value_to_scalar_text(value: &Value) -> Option<String> {
732    match value {
733        Value::String(s) => Some(s.clone()),
734        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
735        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
736        _ => None,
737    }
738}
739
740fn char_array_to_string_array(
741    array: CharArray,
742    _encoding: StringEncoding,
743) -> BuiltinResult<StringArray> {
744    let mut rows: Vec<String> = Vec::with_capacity(array.rows);
745    for r in 0..array.rows {
746        let mut row = String::with_capacity(array.cols);
747        for c in 0..array.cols {
748            row.push(array.data[r * array.cols + c]);
749        }
750        rows.push(row);
751    }
752    let shape = if array.rows == 0 {
753        vec![0, 1]
754    } else {
755        vec![array.rows, 1]
756    };
757    StringArray::new(rows, shape).map_err(|e| string_flow(format!("string: {e}")))
758}
759
760fn tensor_to_string_array(tensor: Tensor) -> BuiltinResult<StringArray> {
761    let mut strings = Vec::with_capacity(tensor.data.len());
762    for &value in &tensor.data {
763        strings.push(number_to_string(value));
764    }
765    StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
766}
767
768fn complex_tensor_to_string_array(tensor: ComplexTensor) -> BuiltinResult<StringArray> {
769    let mut strings = Vec::with_capacity(tensor.data.len());
770    for &(re, im) in &tensor.data {
771        strings.push(complex_to_string(re, im));
772    }
773    StringArray::new(strings, tensor.shape).map_err(|e| string_flow(format!("string: {e}")))
774}
775
776fn logical_array_to_string_array(logical: LogicalArray) -> BuiltinResult<StringArray> {
777    let mut strings = Vec::with_capacity(logical.data.len());
778    for &byte in &logical.data {
779        strings.push(bool_to_string(byte != 0).to_string());
780    }
781    StringArray::new(strings, logical.shape).map_err(|e| string_flow(format!("string: {e}")))
782}
783
784async fn cell_array_to_string_array(
785    cell: runmat_builtins::CellArray,
786    _encoding: StringEncoding,
787) -> BuiltinResult<StringArray> {
788    let mut strings = Vec::with_capacity(cell.data.len());
789    for col in 0..cell.cols {
790        for row in 0..cell.rows {
791            let idx = row * cell.cols + col;
792            let element = &cell.data[idx];
793            let value = (**element).clone();
794            let gathered = gather_if_needed_async(&value)
795                .await
796                .map_err(|flow| remap_string_flow(flow))?;
797            strings.push(cell_element_to_string(&gathered)?);
798        }
799    }
800    StringArray::new(strings, vec![cell.rows, cell.cols])
801        .map_err(|e| string_flow(format!("string: {e}")))
802}
803
804fn cell_element_to_string(value: &Value) -> BuiltinResult<String> {
805    if let Some(array) = crate::builtins::datetime::datetime_string_array(value)
806        .map_err(|err| string_flow(err.message().to_string()))?
807    {
808        if array.data.len() == 1 {
809            return Ok(array.data[0].clone());
810        }
811        return Err(string_flow("string: cell datetime values must be scalar"));
812    }
813    if let Some(array) = crate::builtins::duration::duration_string_array(value)
814        .map_err(|err| string_flow(err.message().to_string()))?
815    {
816        if array.data.len() == 1 {
817            return Ok(array.data[0].clone());
818        }
819        return Err(string_flow("string: cell duration values must be scalar"));
820    }
821    match value {
822        Value::String(s) => Ok(s.clone()),
823        Value::StringArray(sa) => {
824            if sa.data.len() == 1 {
825                Ok(sa.data[0].clone())
826            } else {
827                Err(string_flow(
828                    "string: cell elements must contain string scalars, not string arrays",
829                ))
830            }
831        }
832        Value::CharArray(ca) => {
833            if ca.rows == 1 {
834                Ok(ca.data.iter().collect())
835            } else {
836                Err(string_flow(
837                    "string: cell character arrays must be row vectors",
838                ))
839            }
840        }
841        Value::Num(n) => Ok(number_to_string(*n)),
842        Value::Int(i) => Ok(int_value_to_string(i)),
843        Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
844        Value::LogicalArray(array) => {
845            if array.data.len() == 1 {
846                Ok(bool_to_string(array.data[0] != 0).to_string())
847            } else {
848                Err(string_flow("string: cell logical values must be scalar"))
849            }
850        }
851        Value::Tensor(t) => {
852            if t.data.len() == 1 {
853                Ok(number_to_string(t.data[0]))
854            } else {
855                Err(string_flow("string: cell numeric values must be scalar"))
856            }
857        }
858        Value::Complex(re, im) => Ok(complex_to_string(*re, *im)),
859        Value::ComplexTensor(t) => {
860            if t.data.len() == 1 {
861                let (re, im) = t.data[0];
862                Ok(complex_to_string(re, im))
863            } else {
864                Err(string_flow("string: cell complex values must be scalar"))
865            }
866        }
867        other => Err(string_flow(format!(
868            "string: unsupported cell element type {:?}; expected text or scalar values",
869            other
870        ))),
871    }
872}
873
874fn ensure_sparse_dense_conversion(sparse: &SparseTensor, target: &str) -> BuiltinResult<()> {
875    let total_elements = sparse
876        .rows
877        .checked_mul(sparse.cols)
878        .ok_or_else(|| string_flow("string: sparse matrix dimensions overflow"))?;
879    if total_elements > STRING_SPARSE_DENSE_ELEMENT_LIMIT {
880        return Err(string_flow(format!(
881            "string: cannot convert sparse tensor {}x{} with {} stored entries to {target} ({} elements exceeds safe threshold)",
882            sparse.rows,
883            sparse.cols,
884            sparse.nnz(),
885            total_elements
886        )));
887    }
888    Ok(())
889}
890
891fn bool_to_string(value: bool) -> &'static str {
892    if value {
893        "true"
894    } else {
895        "false"
896    }
897}
898
899fn int_value_to_string(value: &IntValue) -> String {
900    match value {
901        IntValue::I8(v) => v.to_string(),
902        IntValue::I16(v) => v.to_string(),
903        IntValue::I32(v) => v.to_string(),
904        IntValue::I64(v) => v.to_string(),
905        IntValue::U8(v) => v.to_string(),
906        IntValue::U16(v) => v.to_string(),
907        IntValue::U32(v) => v.to_string(),
908        IntValue::U64(v) => v.to_string(),
909    }
910}
911
912#[cfg(test)]
913pub(crate) mod tests {
914    use super::*;
915    use crate::builtins::common::test_support;
916    use runmat_builtins::{CellArray, IntValue, ResolveContext, StringArray, StructValue, Type};
917
918    fn string_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
919        futures::executor::block_on(super::string_builtin(value, rest))
920    }
921
922    fn error_message(err: crate::RuntimeError) -> String {
923        err.message().to_string()
924    }
925
926    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
927    #[test]
928    fn string_from_numeric_scalar() {
929        let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
930        match out {
931            Value::StringArray(sa) => {
932                assert_eq!(sa.shape, vec![1, 1]);
933                assert_eq!(sa.data, vec!["42".to_string()]);
934            }
935            other => panic!("expected string array, got {other:?}"),
936        }
937    }
938
939    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
940    #[test]
941    fn string_from_numeric_tensor_preserves_shape() {
942        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
943        let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
944        match out {
945            Value::StringArray(sa) => {
946                assert_eq!(sa.shape, vec![2, 2]);
947                assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
948            }
949            other => panic!("expected string array, got {other:?}"),
950        }
951    }
952
953    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
954    #[test]
955    fn string_from_logical_array_uses_boolean_text() {
956        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
957        let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
958        match out {
959            Value::StringArray(sa) => {
960                assert_eq!(sa.shape, vec![1, 3]);
961                assert_eq!(sa.data, vec!["true", "false", "true"]);
962            }
963            other => panic!("expected string array, got {other:?}"),
964        }
965    }
966
967    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
968    #[test]
969    fn string_from_char_array_produces_column_vector() {
970        let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
971        let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
972        match out {
973            Value::StringArray(sa) => {
974                assert_eq!(sa.shape, vec![1, 1]);
975                assert_eq!(sa.data, vec!["abc"]);
976            }
977            other => panic!("expected string array, got {other:?}"),
978        }
979    }
980
981    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
982    #[test]
983    fn string_from_cell_array() {
984        let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
985            .expect("cell array");
986        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
987        match out {
988            Value::StringArray(sa) => {
989                assert_eq!(sa.shape, vec![1, 2]);
990                assert_eq!(sa.data, vec!["true", "7"]);
991            }
992            other => panic!("expected string array, got {other:?}"),
993        }
994    }
995
996    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
997    #[test]
998    fn string_from_cell_array_column_major() {
999        let cell = CellArray::new(
1000            vec![
1001                Value::Int(IntValue::I32(1)),
1002                Value::Int(IntValue::I32(2)),
1003                Value::Int(IntValue::I32(3)),
1004                Value::Int(IntValue::I32(4)),
1005            ],
1006            2,
1007            2,
1008        )
1009        .expect("cell array");
1010        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
1011        match out {
1012            Value::StringArray(sa) => {
1013                assert_eq!(sa.shape, vec![2, 2]);
1014                assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
1015            }
1016            other => panic!("expected string array, got {other:?}"),
1017        }
1018    }
1019
1020    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1021    #[test]
1022    fn string_cell_element_requires_scalar_numeric() {
1023        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1024        let cell =
1025            CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
1026        let err = error_message(string_builtin(Value::Cell(cell), Vec::new()).unwrap_err());
1027        assert!(err.contains("cell numeric values must be scalar"));
1028    }
1029
1030    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1031    #[test]
1032    fn string_rejects_struct_input() {
1033        let err = error_message(
1034            string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string"),
1035        );
1036        assert!(err.contains("structs are not supported"));
1037    }
1038
1039    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1040    #[test]
1041    fn string_errors_on_unsupported_encoding() {
1042        let err = error_message(
1043            string_builtin(
1044                Value::CharArray(CharArray::new_row("abc")),
1045                vec![Value::from("UTF-16")],
1046            )
1047            .unwrap_err(),
1048        );
1049        assert!(
1050            err.contains("unsupported character encoding"),
1051            "unexpected error message: {err}"
1052        );
1053    }
1054
1055    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1056    #[test]
1057    fn string_accepts_system_encoding_alias() {
1058        let out = string_builtin(
1059            Value::CharArray(CharArray::new_row("hello")),
1060            vec![Value::from("system")],
1061        )
1062        .expect("string");
1063        match out {
1064            Value::StringArray(sa) => {
1065                assert_eq!(sa.shape, vec![1, 1]);
1066                assert_eq!(sa.data, vec!["hello"]);
1067            }
1068            other => panic!("expected string array, got {other:?}"),
1069        }
1070    }
1071
1072    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1073    #[test]
1074    fn string_encoding_allows_percent_literal() {
1075        let out = string_builtin(
1076            Value::CharArray(CharArray::new_row("100% Done")),
1077            vec![Value::from("utf8")],
1078        )
1079        .expect("string");
1080        match out {
1081            Value::StringArray(sa) => {
1082                assert_eq!(sa.shape, vec![1, 1]);
1083                assert_eq!(sa.data, vec!["100% Done"]);
1084            }
1085            other => panic!("expected string array, got {other:?}"),
1086        }
1087    }
1088
1089    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1090    #[test]
1091    fn string_format_spec_cell_requires_text_scalars() {
1092        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1093        let err = error_message(
1094            string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string"),
1095        );
1096        assert!(
1097            err.contains("formatSpec cell elements must be text scalars"),
1098            "unexpected error: {err}"
1099        );
1100    }
1101
1102    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1103    #[test]
1104    fn string_format_cell_argument_requires_scalar_values() {
1105        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1106        let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1107        let err = error_message(
1108            string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string"),
1109        );
1110        assert!(err.contains("cell format arguments must contain scalar values"));
1111    }
1112
1113    #[test]
1114    fn string_rejects_oversized_sparse_tensor_before_densifying() {
1115        let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1116        let err = string_builtin(Value::SparseTensor(sparse), Vec::new()).unwrap_err();
1117
1118        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1119        assert!(err.message().contains("exceeds safe threshold"));
1120    }
1121
1122    #[test]
1123    fn string_format_rejects_oversized_sparse_argument_before_densifying() {
1124        let sparse = SparseTensor::zeros(STRING_SPARSE_DENSE_ELEMENT_LIMIT + 1, 1);
1125        let err = string_builtin(Value::from("%g"), vec![Value::SparseTensor(sparse)]).unwrap_err();
1126
1127        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1128        assert!(err.message().contains("format argument"));
1129        assert!(err.message().contains("exceeds safe threshold"));
1130    }
1131
1132    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1133    #[test]
1134    fn string_handles_large_unsigned_int() {
1135        let value = Value::Int(IntValue::U64(u64::MAX));
1136        let out = string_builtin(value, Vec::new()).expect("string");
1137        match out {
1138            Value::StringArray(sa) => {
1139                assert_eq!(sa.shape, vec![1, 1]);
1140                assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1141            }
1142            other => panic!("expected string array, got {other:?}"),
1143        }
1144    }
1145
1146    #[test]
1147    fn string_descriptor_signatures_cover_core_forms() {
1148        let labels: Vec<&str> = STRING_DESCRIPTOR
1149            .signatures
1150            .iter()
1151            .map(|signature| signature.label)
1152            .collect();
1153        assert_eq!(
1154            labels,
1155            vec![
1156                "S = string(X)",
1157                "S = string(X, encoding)",
1158                "S = string(formatSpec, A...)",
1159            ]
1160        );
1161
1162        let codes: Vec<&str> = STRING_DESCRIPTOR
1163            .errors
1164            .iter()
1165            .map(|error| error.code)
1166            .collect();
1167        assert_eq!(codes, vec!["RM.STRING.INVALID_INPUT"]);
1168    }
1169
1170    #[test]
1171    fn string_struct_input_uses_stable_identifier() {
1172        let err = string_builtin(Value::Struct(StructValue::new()), Vec::new()).unwrap_err();
1173        assert_eq!(err.identifier(), Some("RunMat:string:InvalidInput"));
1174    }
1175
1176    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1177    #[test]
1178    fn string_format_numeric_scalar() {
1179        let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1180        match out {
1181            Value::StringArray(sa) => {
1182                assert_eq!(sa.shape, vec![1, 1]);
1183                assert_eq!(sa.data, vec!["7"]);
1184            }
1185            other => panic!("expected string array, got {other:?}"),
1186        }
1187    }
1188
1189    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1190    #[test]
1191    fn string_format_broadcast_over_tensor() {
1192        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1193        let out =
1194            string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1195        match out {
1196            Value::StringArray(sa) => {
1197                assert_eq!(sa.shape, vec![1, 3]);
1198                assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1199            }
1200            other => panic!("expected string array, got {other:?}"),
1201        }
1202    }
1203
1204    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1205    #[test]
1206    fn string_format_string_array_spec_alignment() {
1207        let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1208        let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1209        let out =
1210            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1211        match out {
1212            Value::StringArray(sa) => {
1213                assert_eq!(sa.shape, vec![1, 2]);
1214                assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1215            }
1216            other => panic!("expected string array, got {other:?}"),
1217        }
1218    }
1219
1220    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1221    #[test]
1222    fn string_format_prefers_placeholders_over_encoding_hint() {
1223        let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1224        match out {
1225            Value::StringArray(sa) => {
1226                assert_eq!(sa.shape, vec![1, 1]);
1227                assert_eq!(sa.data, vec!["UTF-8"]);
1228            }
1229            other => panic!("expected string array, got {other:?}"),
1230        }
1231    }
1232
1233    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1234    #[test]
1235    fn string_format_mismatched_lengths_errors() {
1236        let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1237        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1238        let err = error_message(
1239            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err(),
1240        );
1241        assert!(err.contains("must be scalars or match formatSpec size"));
1242    }
1243
1244    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1245    #[test]
1246    fn string_gpu_numeric_tensor() {
1247        test_support::with_test_provider(|provider| {
1248            let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1249            let view = runmat_accelerate_api::HostTensorView {
1250                data: &tensor.data,
1251                shape: &tensor.shape,
1252            };
1253            let handle = provider.upload(&view).expect("upload");
1254            let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1255                .expect("gpu string conversion");
1256            match result {
1257                Value::StringArray(sa) => {
1258                    assert_eq!(sa.shape, vec![1, 2]);
1259                    assert_eq!(sa.data, vec!["10", "20"]);
1260                }
1261                other => panic!("expected string array, got {other:?}"),
1262            }
1263        });
1264    }
1265
1266    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1267    #[test]
1268    #[cfg(feature = "wgpu")]
1269    fn string_wgpu_numeric_tensor_matches_cpu() {
1270        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1271            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1272        );
1273        let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1274        let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1275            .expect("cpu string conversion");
1276        let view = runmat_accelerate_api::HostTensorView {
1277            data: &tensor.data,
1278            shape: &tensor.shape,
1279        };
1280        let handle = runmat_accelerate_api::provider()
1281            .unwrap()
1282            .upload(&view)
1283            .expect("gpu upload");
1284        let gpu =
1285            string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1286        match (cpu, gpu) {
1287            (Value::StringArray(expect), Value::StringArray(actual)) => {
1288                assert_eq!(actual.shape, expect.shape);
1289                assert_eq!(actual.data, expect.data);
1290            }
1291            other => panic!("unexpected results {other:?}"),
1292        }
1293    }
1294
1295    #[test]
1296    fn string_type_is_string_array() {
1297        assert_eq!(
1298            string_array_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1299            Type::cell_of(Type::String)
1300        );
1301    }
1302}