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