runmat_runtime/builtins/strings/core/
string.rs

1//! MATLAB-compatible `string` builtin with GPU-aware conversion semantics for RunMat.
2
3use runmat_builtins::{
4    CharArray, ComplexTensor, IntValue, LogicalArray, StringArray, Tensor, Value,
5};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::format::format_variadic;
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ShapeRequirements,
12};
13use crate::builtins::common::tensor;
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18#[cfg(feature = "doc_export")]
19pub const DOC_MD: &str = r#"---
20title: "string"
21category: "strings/core"
22keywords: ["string", "convert", "text", "char", "gpu"]
23summary: "Convert numeric, logical, character, and text inputs into MATLAB string arrays, with optional format-spec composition."
24references:
25  - https://www.mathworks.com/help/matlab/ref/string.html
26gpu_support:
27  elementwise: false
28  reduction: false
29  precisions: []
30  broadcasting: "none"
31  notes: "Gather-only conversion that downloads GPU tensors to host memory before producing string scalars."
32fusion:
33  elementwise: false
34  reduction: false
35  max_inputs: 1
36  constants: "inline"
37requires_feature: null
38tested:
39  unit: "builtins::strings::core::string::tests"
40  integration: "builtins::strings::core::string::tests::string_gpu_numeric_tensor"
41---
42
43# What does the `string` function do in MATLAB / RunMat?
44`string(x)` converts scalars, arrays, and text-like inputs into MATLAB string arrays whose elements
45are string scalars. Numeric and logical values are formatted using MATLAB's short-`g` style,
46character arrays are split by row, and existing string arrays pass through unchanged.
47
48## How does the `string` function behave in MATLAB / RunMat?
49- Scalar inputs return a `1×1` string array containing the converted value.
50- Numeric and logical arrays preserve the original shape while converting each element.
51- Character arrays turn into columnar string arrays with one row per original row.
52- Cell arrays must contain text-like or scalar numeric values; each cell becomes one string scalar.
53- `string(formatSpec, A1, ..., An)` formats data using MATLAB-compatible `%` placeholders. Scalar
54  format specs broadcast across array arguments and arrays of specs align element-wise.
55- Empty inputs yield empty string arrays that match MATLAB's dimension rules.
56- Unsupported types (structs, handle objects, events, functions) raise MATLAB-compatible errors.
57
58## `string` Function GPU Execution Behaviour
59`string` is a residency sink. When the input contains GPU tensors, RunMat gathers the data back to
60host memory before performing the conversion. Providers do not need bespoke kernels—the CPU path is
61authoritative and ensures identical formatting across devices.
62
63## Examples of using the `string` function in MATLAB / RunMat
64
65### Converting A Numeric Scalar To A String
66```matlab
67name = string(42);
68```
69Expected output:
70```matlab
71name = "42"
72```
73
74### Turning A Numeric Row Vector Into Strings
75```matlab
76values = string([3.14159 2.71828 1.41421]);
77```
78Expected output:
79```matlab
80values = 1×3 string
81    "3.1416"    "2.7183"    "1.4142"
82```
83
84### Converting A Character Matrix Into A String Array
85```matlab
86C = ['North '; 'South '; 'East  '; 'West  '];
87regions = string(C);
88```
89Expected output:
90```matlab
91regions = 4×1 string
92    "North "
93    "South "
94    "East  "
95    "West  "
96```
97
98### Converting Logical Data To String Scalars
99```matlab
100flags = string(logical([1 0 1 0]));
101```
102Expected output:
103```matlab
104flags = 1×4 string
105    "true"    "false"    "true"    "false"
106```
107
108### Creating Strings From A Cell Array Of Mixed Scalars
109```matlab
110C = {true, 17, "runmat"};
111S = string(C);
112```
113Expected output:
114```matlab
115S = 1×3 string
116    "true"    "17"    "runmat"
117```
118
119### Formatting Numbers With A Template
120```matlab
121labels = string("Trial %d", 1:4);
122```
123Expected output:
124```matlab
125labels = 1×4 string
126    "Trial 1"    "Trial 2"    "Trial 3"    "Trial 4"
127```
128
129### Converting GPU-Resident Numeric Data To Strings
130```matlab
131G = gpuArray([10 20 30]);
132labels = string(G);
133```
134Expected output:
135```matlab
136labels = 1×3 string
137    "10"    "20"    "30"
138```
139RunMat gathers the GPU tensor to host memory automatically before formatting.
140
141## FAQ
142
143### Does `string` change the size of my array?
144No. Array-shaped inputs return string arrays with the same shape. Character arrays become column
145vectors where each row of characters maps to one string scalar.
146
147### How are floating-point numbers formatted?
148Floating-point values use MATLAB's short-`g` formatting (up to 12 significant digits) so the result
149matches `disp` output and is consistent across CPU and GPU inputs.
150
151### Can I use format specifiers like `%0.2f`?
152Yes. Provide a format string as the first argument and pass the values to substitute in the
153remaining arguments, e.g. `string("Value %0.2f", A)` or `string(["X%02d" "Y%02d"], 1:2)`. Scalar
154format specs broadcast across vector inputs following MATLAB's rules.
155
156### What happens if I pass a GPU tensor?
157The builtin downloads the tensor using the active acceleration provider and then performs the
158conversion on the CPU. The resulting string array always resides in host memory.
159
160### Can I request a specific character encoding?
161RunMat currently supports UTF-8 (the default). Passing `'UTF-8'`, `'utf8'`, or `'system'` yields the
162same behaviour. Other encodings raise a descriptive error.
163
164### Can I convert complex numbers or complex arrays?
165Yes. Complex scalars and arrays use MATLAB's `a + bi` formatting, with imaginary values rendered
166using the `i` suffix.
167
168### What happens with empty inputs?
169Empty inputs return empty string arrays following MATLAB's dimension rules—for example,
170`string([])` yields a `0×0` string array, and `string(char.empty(0,5))` yields a `0×1` string array.
171
172### Why does `string` error on structs or handle objects?
173MATLAB's `string` only supports text-like or scalar numeric types. Structs, objects, listeners, and
174other handle types cannot be converted automatically and therefore raise an error in RunMat as well.
175
176### How can I keep trailing spaces from character arrays?
177`string` preserves every character, including trailing spaces. Use `strtrim` afterwards if you want
178to remove padding.
179
180### Do existing string arrays change when passed to `string`?
181No. Existing string arrays pass through unchanged, so `string(["a" "b"])` returns the same array.
182
183## See Also
184`char`, `cellstr`, `string.empty`, `strings`
185"#;
186
187pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
188    name: "string",
189    op_kind: GpuOpKind::Custom("conversion"),
190    supported_precisions: &[],
191    broadcast: BroadcastSemantics::None,
192    provider_hooks: &[],
193    constant_strategy: ConstantStrategy::InlineLiteral,
194    residency: ResidencyPolicy::GatherImmediately,
195    nan_mode: ReductionNaN::Include,
196    two_pass_threshold: None,
197    workgroup_size: None,
198    accepts_nan_mode: false,
199    notes: "Always converts on the CPU; GPU tensors are gathered to host memory before conversion.",
200};
201
202register_builtin_gpu_spec!(GPU_SPEC);
203
204pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
205    name: "string",
206    shape: ShapeRequirements::Any,
207    constant_strategy: ConstantStrategy::InlineLiteral,
208    elementwise: None,
209    reduction: None,
210    emits_nan: false,
211    notes:
212        "Conversion builtin; not eligible for fusion and always materialises host string arrays.",
213};
214
215register_builtin_fusion_spec!(FUSION_SPEC);
216
217#[cfg(feature = "doc_export")]
218register_builtin_doc_text!("string", DOC_MD);
219
220#[runtime_builtin(
221    name = "string",
222    category = "strings/core",
223    summary = "Convert numeric, logical, and text inputs into MATLAB string arrays.",
224    keywords = "string,convert,text,char,gpu",
225    accel = "sink"
226)]
227fn string_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
228    if rest.is_empty() {
229        let gathered = gather_if_needed(&value).map_err(|e| format!("string: {e}"))?;
230        let array = convert_to_string_array(gathered, StringEncoding::Utf8)?;
231        return Ok(Value::StringArray(array));
232    }
233
234    let mut args = rest;
235    let format_value = gather_if_needed(&value).map_err(|e| format!("string: {e}"))?;
236
237    if args.len() == 1 {
238        let arg = args.pop().unwrap();
239        let gathered_arg = gather_if_needed(&arg).map_err(|e| format!("string: {e}"))?;
240        if let Some(encoding) = try_encoding_argument(&format_value, &gathered_arg)? {
241            let array = convert_to_string_array(format_value, encoding)?;
242            return Ok(Value::StringArray(array));
243        }
244        let formatted = format_from_spec(format_value, vec![gathered_arg])?;
245        return Ok(Value::StringArray(formatted));
246    }
247
248    let gathered_args = args
249        .into_iter()
250        .map(|arg| gather_if_needed(&arg).map_err(|e| format!("string: {e}")))
251        .collect::<Result<Vec<_>, _>>()?;
252    let formatted = format_from_spec(format_value, gathered_args)?;
253    Ok(Value::StringArray(formatted))
254}
255
256#[derive(Clone, Copy, Debug, PartialEq, Eq)]
257enum StringEncoding {
258    Utf8,
259}
260
261fn try_encoding_argument(
262    first: &Value,
263    candidate: &Value,
264) -> Result<Option<StringEncoding>, String> {
265    if !matches!(
266        first,
267        Value::CharArray(_) | Value::String(_) | Value::StringArray(_) | Value::Cell(_)
268    ) {
269        return Ok(None);
270    }
271    if has_format_placeholders(first) {
272        return Ok(None);
273    }
274    if let Value::Cell(cell) = first {
275        if !cell_contains_only_text_scalars(cell) {
276            return Ok(None);
277        }
278    }
279    let Some(text) = value_to_scalar_text(candidate) else {
280        return Ok(None);
281    };
282    parse_encoding_text(&text).map(Some)
283}
284
285fn parse_encoding_text(raw: &str) -> Result<StringEncoding, String> {
286    let trimmed = raw.trim();
287    let lowered = trimmed.to_ascii_lowercase();
288    match lowered.as_str() {
289        "utf-8" | "utf8" | "unicode" | "system" => Ok(StringEncoding::Utf8),
290        _ => Err(format!(
291            "string: unsupported character encoding '{trimmed}'; only UTF-8 is available"
292        )),
293    }
294}
295
296fn cell_contains_only_text_scalars(cell: &runmat_builtins::CellArray) -> bool {
297    cell.data.iter().all(|ptr| match &**ptr {
298        Value::String(_) => true,
299        Value::StringArray(sa) => sa.data.len() <= 1,
300        Value::CharArray(ca) => ca.rows <= 1,
301        _ => false,
302    })
303}
304
305fn text_has_format_placeholder(text: &str) -> bool {
306    let mut chars = text.chars().peekable();
307    while let Some(ch) = chars.next() {
308        if ch != '%' {
309            continue;
310        }
311        if let Some('%') = chars.peek() {
312            chars.next();
313            continue;
314        }
315        while matches!(chars.peek(), Some(flag) if matches!(flag, '+' | '-' | '0' | '#')) {
316            chars.next();
317        }
318        while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
319            chars.next();
320        }
321        if let Some('.') = chars.peek() {
322            chars.next();
323            while matches!(chars.peek(), Some(digit) if digit.is_ascii_digit()) {
324                chars.next();
325            }
326        }
327        if let Some(conv) = chars.peek() {
328            if conv.is_ascii_alphabetic() {
329                return true;
330            }
331        }
332    }
333    false
334}
335
336fn has_format_placeholders(value: &Value) -> bool {
337    match value {
338        Value::String(s) => text_has_format_placeholder(s),
339        Value::StringArray(sa) => sa.data.iter().any(|s| text_has_format_placeholder(s)),
340        Value::CharArray(ca) => {
341            for row in 0..ca.rows {
342                let mut row_str = String::with_capacity(ca.cols);
343                for col in 0..ca.cols {
344                    row_str.push(ca.data[row * ca.cols + col]);
345                }
346                if text_has_format_placeholder(&row_str) {
347                    return true;
348                }
349            }
350            false
351        }
352        Value::Cell(cell) => {
353            for ptr in &cell.data {
354                let element = (**ptr).clone();
355                if has_format_placeholders(&element) {
356                    return true;
357                }
358            }
359            false
360        }
361        _ => false,
362    }
363}
364
365pub(crate) struct FormatSpecData {
366    pub(crate) specs: Vec<String>,
367    pub(crate) shape: Vec<usize>,
368}
369
370struct ArgumentData {
371    values: Vec<Value>,
372    shape: Vec<usize>,
373}
374
375pub(crate) fn format_from_spec(
376    format_value: Value,
377    args: Vec<Value>,
378) -> Result<StringArray, String> {
379    let spec = extract_format_spec(format_value)?;
380    let mut arguments = Vec::with_capacity(args.len());
381    for arg in args {
382        arguments.push(extract_argument_data(arg)?);
383    }
384
385    let (target_len, mut target_shape) = resolve_target_shape(&spec, &arguments)?;
386
387    if target_len == 0 {
388        let shape = if target_shape.is_empty() {
389            if spec.shape.is_empty() {
390                vec![0, 0]
391            } else {
392                spec.shape.clone()
393            }
394        } else {
395            target_shape
396        };
397        return StringArray::new(Vec::new(), shape).map_err(|e| format!("string: {e}"));
398    }
399
400    let spec_len = spec.specs.len();
401    if spec_len == 0 {
402        return Err(
403            "string: formatSpec must contain at least one element when formatting with data"
404                .to_string(),
405        );
406    }
407
408    for arg in &arguments {
409        if target_len > 0 && arg.values.is_empty() {
410            return Err(
411                "string: format data arguments must be scalars or match formatSpec size"
412                    .to_string(),
413            );
414        }
415    }
416
417    let mut output = Vec::with_capacity(target_len);
418    for idx in 0..target_len {
419        let spec_idx = if spec_len == 1 { 0 } else { idx };
420        let spec_str = &spec.specs[spec_idx];
421        let mut per_call = Vec::with_capacity(arguments.len());
422        for arg in &arguments {
423            let value =
424                match arg.values.len() {
425                    0 => continue,
426                    1 => arg.values[0].clone(),
427                    len if len == target_len => arg.values[idx].clone(),
428                    _ => return Err(
429                        "string: format data arguments must be scalars or match formatSpec size"
430                            .to_string(),
431                    ),
432                };
433            per_call.push(value);
434        }
435        let formatted = format_variadic(spec_str, &per_call).map_err(|e| format!("string: {e}"))?;
436        output.push(formatted);
437    }
438
439    if target_shape.is_empty() {
440        target_shape = if spec_len > 1 {
441            spec.shape.clone()
442        } else {
443            vec![target_len, 1]
444        };
445    }
446
447    if tensor::element_count(&target_shape) != target_len {
448        target_shape = vec![target_len, 1];
449    }
450
451    StringArray::new(output, target_shape).map_err(|e| format!("string: {e}"))
452}
453
454fn resolve_target_shape(
455    spec: &FormatSpecData,
456    args: &[ArgumentData],
457) -> Result<(usize, Vec<usize>), String> {
458    let mut target_len = spec.specs.len();
459    let mut target_shape = if target_len > 1 || (target_len == 1 && !spec.shape.is_empty()) {
460        spec.shape.clone()
461    } else {
462        Vec::new()
463    };
464
465    for arg in args {
466        let len = arg.values.len();
467        if len == 0 {
468            continue;
469        }
470        if target_len == 0 {
471            target_len = len;
472            target_shape = arg.shape.clone();
473            continue;
474        }
475        if len == 1 {
476            continue;
477        }
478        if target_len == 1 {
479            target_len = len;
480            target_shape = arg.shape.clone();
481            continue;
482        }
483        if len != target_len {
484            return Err(
485                "string: format data arguments must be scalars or match formatSpec size"
486                    .to_string(),
487            );
488        }
489        if target_shape.is_empty() && len > 1 {
490            target_shape = arg.shape.clone();
491        }
492    }
493
494    if target_len == 0 {
495        let shape = if spec.shape.is_empty() {
496            vec![0, 0]
497        } else {
498            spec.shape.clone()
499        };
500        return Ok((0, shape));
501    }
502
503    if target_shape.is_empty() {
504        target_shape = if spec.shape.is_empty() {
505            vec![target_len, 1]
506        } else {
507            spec.shape.clone()
508        };
509        if spec.specs.len() == 1 && tensor::element_count(&target_shape) != target_len {
510            target_shape = vec![target_len, 1];
511        }
512    }
513
514    if tensor::element_count(&target_shape) != target_len {
515        target_shape = vec![target_len, 1];
516    }
517
518    Ok((target_len, target_shape))
519}
520
521pub(crate) fn extract_format_spec(value: Value) -> Result<FormatSpecData, String> {
522    match value {
523        Value::String(s) => Ok(FormatSpecData {
524            specs: vec![s],
525            shape: vec![1, 1],
526        }),
527        Value::StringArray(sa) => Ok(FormatSpecData {
528            specs: sa.data.clone(),
529            shape: sa.shape.clone(),
530        }),
531        Value::CharArray(ca) => {
532            let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
533            Ok(FormatSpecData {
534                specs: array.data,
535                shape: array.shape,
536            })
537        }
538        Value::Cell(cell) => {
539            let mut specs = Vec::with_capacity(cell.data.len());
540            for col in 0..cell.cols {
541                for row in 0..cell.rows {
542                    let idx = row * cell.cols + col;
543                    let element = &cell.data[idx];
544                    let value = (**element).clone();
545                    let gathered = gather_if_needed(&value).map_err(|e| format!("string: {e}"))?;
546                    let text = value_to_scalar_text(&gathered).ok_or_else(|| {
547                        "string: formatSpec cell elements must be text scalars".to_string()
548                    })?;
549                    specs.push(text);
550                }
551            }
552            Ok(FormatSpecData {
553                specs,
554                shape: vec![cell.rows, cell.cols],
555            })
556        }
557        _ => Err("string: formatSpec must be text (string, char, or cellstr)".to_string()),
558    }
559}
560
561fn extract_argument_data(value: Value) -> Result<ArgumentData, String> {
562    match value {
563        Value::String(s) => Ok(ArgumentData {
564            values: vec![Value::String(s)],
565            shape: vec![1, 1],
566        }),
567        Value::StringArray(sa) => Ok(ArgumentData {
568            values: sa.data.into_iter().map(Value::String).collect(),
569            shape: sa.shape,
570        }),
571        Value::CharArray(ca) => {
572            let array = char_array_to_string_array(ca, StringEncoding::Utf8)?;
573            Ok(ArgumentData {
574                values: array.data.into_iter().map(Value::String).collect(),
575                shape: array.shape,
576            })
577        }
578        Value::Num(n) => Ok(ArgumentData {
579            values: vec![Value::Num(n)],
580            shape: vec![1, 1],
581        }),
582        Value::Int(i) => Ok(ArgumentData {
583            values: vec![Value::Int(i)],
584            shape: vec![1, 1],
585        }),
586        Value::Bool(b) => Ok(ArgumentData {
587            values: vec![Value::Num(if b { 1.0 } else { 0.0 })],
588            shape: vec![1, 1],
589        }),
590        Value::Tensor(t) => Ok(ArgumentData {
591            values: t.data.into_iter().map(Value::Num).collect(),
592            shape: t.shape,
593        }),
594        Value::Complex(re, im) => Ok(ArgumentData {
595            values: vec![Value::String(Value::Complex(re, im).to_string())],
596            shape: vec![1, 1],
597        }),
598        Value::ComplexTensor(t) => Ok(ArgumentData {
599            values: t
600                .data
601                .into_iter()
602                .map(|(re, im)| Value::String(Value::Complex(re, im).to_string()))
603                .collect(),
604            shape: t.shape,
605        }),
606        Value::LogicalArray(la) => Ok(ArgumentData {
607            values: la
608                .data
609                .into_iter()
610                .map(|byte| Value::Num(if byte != 0 { 1.0 } else { 0.0 }))
611                .collect(),
612            shape: la.shape,
613        }),
614        Value::Cell(cell) => {
615            let mut values = Vec::with_capacity(cell.data.len());
616            for col in 0..cell.cols {
617                for row in 0..cell.rows {
618                    let idx = row * cell.cols + col;
619                    let element = &cell.data[idx];
620                    let value = (**element).clone();
621                    let gathered = gather_if_needed(&value).map_err(|e| format!("string: {e}"))?;
622                    let value = match gathered {
623                        Value::String(s) => Value::String(s),
624                        Value::StringArray(sa) if sa.data.len() == 1 => {
625                            Value::String(sa.data[0].clone())
626                        }
627                        Value::CharArray(ca) => {
628                            if ca.rows != 1 {
629                                return Err(
630                                    "string: cell format arguments must contain char row vectors".to_string(),
631                                );
632                            }
633                            let mut row_str = String::with_capacity(ca.cols);
634                            for ch in ca.data {
635                                row_str.push(ch);
636                            }
637                            Value::String(row_str)
638                        }
639                        Value::Num(n) => Value::Num(n),
640                        Value::Int(i) => Value::Int(i),
641                        Value::Bool(b) => Value::Num(if b { 1.0 } else { 0.0 }),
642                        Value::Tensor(t) => {
643                            if t.data.len() != 1 {
644                                return Err(
645                                    "string: cell format arguments must contain scalar values"
646                                        .to_string(),
647                                );
648                            }
649                            Value::Num(t.data[0])
650                        }
651                        Value::LogicalArray(la) => {
652                            if la.data.len() != 1 {
653                                return Err(
654                                    "string: cell format arguments must contain scalar values"
655                                        .to_string(),
656                                );
657                            }
658                            Value::Num(if la.data[0] != 0 { 1.0 } else { 0.0 })
659                        }
660                        Value::Complex(re, im) => {
661                            Value::String(Value::Complex(re, im).to_string())
662                        }
663                        Value::ComplexTensor(t) => {
664                            if t.data.len() != 1 {
665                                return Err(
666                                    "string: cell format arguments must contain scalar values"
667                                        .to_string(),
668                                );
669                            }
670                            let (re, im) = t.data[0];
671                            Value::String(Value::Complex(re, im).to_string())
672                        }
673                        other => {
674                            return Err(format!(
675                                "string: unsupported cell format argument {other:?}; expected scalar text or numeric values"
676                            ))
677                        }
678                    };
679                    values.push(value);
680                }
681            }
682            Ok(ArgumentData {
683                values,
684                shape: vec![cell.rows, cell.cols],
685            })
686        }
687        Value::GpuTensor(handle) => {
688            let gathered =
689                gather_if_needed(&Value::GpuTensor(handle)).map_err(|e| format!("string: {e}"))?;
690            extract_argument_data(gathered)
691        }
692        Value::MException(_)
693        | Value::HandleObject(_)
694        | Value::Object(_)
695        | Value::Listener(_)
696        | Value::Struct(_) => Err("string: unsupported format argument type".to_string()),
697        Value::FunctionHandle(_) | Value::Closure(_) | Value::ClassRef(_) => {
698            Err("string: unsupported format argument type".to_string())
699        }
700    }
701}
702
703fn convert_to_string_array(value: Value, encoding: StringEncoding) -> Result<StringArray, String> {
704    match value {
705        Value::String(s) => string_scalar(s),
706        Value::StringArray(sa) => Ok(sa),
707        Value::CharArray(ca) => char_array_to_string_array(ca, encoding),
708        Value::Tensor(tensor) => tensor_to_string_array(tensor),
709        Value::ComplexTensor(tensor) => complex_tensor_to_string_array(tensor),
710        Value::LogicalArray(logical) => logical_array_to_string_array(logical),
711        Value::Cell(cell) => cell_array_to_string_array(cell, encoding),
712        Value::Num(n) => string_scalar(Value::Num(n).to_string()),
713        Value::Int(i) => string_scalar(int_value_to_string(&i)),
714        Value::Bool(b) => string_scalar(bool_to_string(b).to_string()),
715        Value::Complex(re, im) => string_scalar(Value::Complex(re, im).to_string()),
716        Value::GpuTensor(handle) => {
717            // Defensive fallback: gather and retry.
718            let gathered = gather_if_needed(&Value::GpuTensor(handle))
719                .map_err(|e| format!("string: {e}"))?;
720            convert_to_string_array(gathered, encoding)
721        }
722        Value::Object(_) | Value::HandleObject(_) | Value::Listener(_) => Err(
723            "string: unsupported conversion from handle-based objects. Use class-specific formatters."
724                .to_string(),
725        ),
726        Value::Struct(_) => Err("string: structs are not supported for automatic conversion".to_string()),
727        Value::FunctionHandle(_) | Value::Closure(_) | Value::ClassRef(_) | Value::MException(_) => Err(
728            "string: unsupported conversion for function or exception handles".to_string(),
729        ),
730    }
731}
732
733fn string_scalar<S: Into<String>>(text: S) -> Result<StringArray, String> {
734    StringArray::new(vec![text.into()], vec![1, 1]).map_err(|e| format!("string: {e}"))
735}
736
737fn value_to_scalar_text(value: &Value) -> Option<String> {
738    match value {
739        Value::String(s) => Some(s.clone()),
740        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
741        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
742        _ => None,
743    }
744}
745
746fn char_array_to_string_array(
747    array: CharArray,
748    _encoding: StringEncoding,
749) -> Result<StringArray, String> {
750    let mut rows: Vec<String> = Vec::with_capacity(array.rows);
751    for r in 0..array.rows {
752        let mut row = String::with_capacity(array.cols);
753        for c in 0..array.cols {
754            row.push(array.data[r * array.cols + c]);
755        }
756        rows.push(row);
757    }
758    let shape = if array.rows == 0 {
759        vec![0, 1]
760    } else {
761        vec![array.rows, 1]
762    };
763    StringArray::new(rows, shape).map_err(|e| format!("string: {e}"))
764}
765
766fn tensor_to_string_array(tensor: Tensor) -> Result<StringArray, String> {
767    let mut strings = Vec::with_capacity(tensor.data.len());
768    for &value in &tensor.data {
769        strings.push(Value::Num(value).to_string());
770    }
771    StringArray::new(strings, tensor.shape).map_err(|e| format!("string: {e}"))
772}
773
774fn complex_tensor_to_string_array(tensor: ComplexTensor) -> Result<StringArray, String> {
775    let mut strings = Vec::with_capacity(tensor.data.len());
776    for &(re, im) in &tensor.data {
777        strings.push(Value::Complex(re, im).to_string());
778    }
779    StringArray::new(strings, tensor.shape).map_err(|e| format!("string: {e}"))
780}
781
782fn logical_array_to_string_array(logical: LogicalArray) -> Result<StringArray, String> {
783    let mut strings = Vec::with_capacity(logical.data.len());
784    for &byte in &logical.data {
785        strings.push(bool_to_string(byte != 0).to_string());
786    }
787    StringArray::new(strings, logical.shape).map_err(|e| format!("string: {e}"))
788}
789
790fn cell_array_to_string_array(
791    cell: runmat_builtins::CellArray,
792    _encoding: StringEncoding,
793) -> Result<StringArray, String> {
794    let mut strings = Vec::with_capacity(cell.data.len());
795    for col in 0..cell.cols {
796        for row in 0..cell.rows {
797            let idx = row * cell.cols + col;
798            let element = &cell.data[idx];
799            let value = (**element).clone();
800            let gathered = gather_if_needed(&value).map_err(|e| format!("string: {e}"))?;
801            strings.push(cell_element_to_string(&gathered)?);
802        }
803    }
804    StringArray::new(strings, vec![cell.rows, cell.cols]).map_err(|e| format!("string: {e}"))
805}
806
807fn cell_element_to_string(value: &Value) -> Result<String, String> {
808    match value {
809        Value::String(s) => Ok(s.clone()),
810        Value::StringArray(sa) => {
811            if sa.data.len() == 1 {
812                Ok(sa.data[0].clone())
813            } else {
814                Err(
815                    "string: cell elements must contain string scalars, not string arrays"
816                        .to_string(),
817                )
818            }
819        }
820        Value::CharArray(ca) => {
821            if ca.rows == 1 {
822                Ok(ca.data.iter().collect())
823            } else {
824                Err("string: cell character arrays must be row vectors".to_string())
825            }
826        }
827        Value::Num(n) => Ok(Value::Num(*n).to_string()),
828        Value::Int(i) => Ok(int_value_to_string(i)),
829        Value::Bool(b) => Ok(bool_to_string(*b).to_string()),
830        Value::LogicalArray(array) => {
831            if array.data.len() == 1 {
832                Ok(bool_to_string(array.data[0] != 0).to_string())
833            } else {
834                Err("string: cell logical values must be scalar".to_string())
835            }
836        }
837        Value::Tensor(t) => {
838            if t.data.len() == 1 {
839                Ok(Value::Num(t.data[0]).to_string())
840            } else {
841                Err("string: cell numeric values must be scalar".to_string())
842            }
843        }
844        Value::Complex(re, im) => Ok(Value::Complex(*re, *im).to_string()),
845        Value::ComplexTensor(t) => {
846            if t.data.len() == 1 {
847                let (re, im) = t.data[0];
848                Ok(Value::Complex(re, im).to_string())
849            } else {
850                Err("string: cell complex values must be scalar".to_string())
851            }
852        }
853        other => Err(format!(
854            "string: unsupported cell element type {:?}; expected text or scalar values",
855            other
856        )),
857    }
858}
859
860fn bool_to_string(value: bool) -> &'static str {
861    if value {
862        "true"
863    } else {
864        "false"
865    }
866}
867
868fn int_value_to_string(value: &IntValue) -> String {
869    match value {
870        IntValue::I8(v) => v.to_string(),
871        IntValue::I16(v) => v.to_string(),
872        IntValue::I32(v) => v.to_string(),
873        IntValue::I64(v) => v.to_string(),
874        IntValue::U8(v) => v.to_string(),
875        IntValue::U16(v) => v.to_string(),
876        IntValue::U32(v) => v.to_string(),
877        IntValue::U64(v) => v.to_string(),
878    }
879}
880
881#[cfg(test)]
882mod tests {
883    use super::*;
884    use crate::builtins::common::test_support;
885    use runmat_builtins::{CellArray, IntValue, StringArray, StructValue};
886
887    #[test]
888    fn string_from_numeric_scalar() {
889        let out = string_builtin(Value::Num(42.0), Vec::new()).expect("string");
890        match out {
891            Value::StringArray(sa) => {
892                assert_eq!(sa.shape, vec![1, 1]);
893                assert_eq!(sa.data, vec!["42".to_string()]);
894            }
895            other => panic!("expected string array, got {other:?}"),
896        }
897    }
898
899    #[test]
900    fn string_from_numeric_tensor_preserves_shape() {
901        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
902        let out = string_builtin(Value::Tensor(tensor), Vec::new()).expect("string");
903        match out {
904            Value::StringArray(sa) => {
905                assert_eq!(sa.shape, vec![2, 2]);
906                assert_eq!(sa.data, vec!["1", "2", "3", "4"]);
907            }
908            other => panic!("expected string array, got {other:?}"),
909        }
910    }
911
912    #[test]
913    fn string_from_logical_array_uses_boolean_text() {
914        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
915        let out = string_builtin(Value::LogicalArray(logical), Vec::new()).expect("string");
916        match out {
917            Value::StringArray(sa) => {
918                assert_eq!(sa.shape, vec![1, 3]);
919                assert_eq!(sa.data, vec!["true", "false", "true"]);
920            }
921            other => panic!("expected string array, got {other:?}"),
922        }
923    }
924
925    #[test]
926    fn string_from_char_array_produces_column_vector() {
927        let chars = CharArray::new("abc".chars().collect(), 1, 3).unwrap();
928        let out = string_builtin(Value::CharArray(chars), Vec::new()).expect("string");
929        match out {
930            Value::StringArray(sa) => {
931                assert_eq!(sa.shape, vec![1, 1]);
932                assert_eq!(sa.data, vec!["abc"]);
933            }
934            other => panic!("expected string array, got {other:?}"),
935        }
936    }
937
938    #[test]
939    fn string_from_cell_array() {
940        let cell = CellArray::new(vec![Value::Bool(true), Value::Int(IntValue::I32(7))], 1, 2)
941            .expect("cell array");
942        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
943        match out {
944            Value::StringArray(sa) => {
945                assert_eq!(sa.shape, vec![1, 2]);
946                assert_eq!(sa.data, vec!["true", "7"]);
947            }
948            other => panic!("expected string array, got {other:?}"),
949        }
950    }
951
952    #[test]
953    fn string_from_cell_array_column_major() {
954        let cell = CellArray::new(
955            vec![
956                Value::Int(IntValue::I32(1)),
957                Value::Int(IntValue::I32(2)),
958                Value::Int(IntValue::I32(3)),
959                Value::Int(IntValue::I32(4)),
960            ],
961            2,
962            2,
963        )
964        .expect("cell array");
965        let out = string_builtin(Value::Cell(cell), Vec::new()).expect("string");
966        match out {
967            Value::StringArray(sa) => {
968                assert_eq!(sa.shape, vec![2, 2]);
969                assert_eq!(sa.data, vec!["1", "3", "2", "4"]);
970            }
971            other => panic!("expected string array, got {other:?}"),
972        }
973    }
974
975    #[test]
976    fn string_cell_element_requires_scalar_numeric() {
977        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
978        let cell =
979            CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell with numeric tensor");
980        let err = string_builtin(Value::Cell(cell), Vec::new()).unwrap_err();
981        assert!(err.contains("cell numeric values must be scalar"));
982    }
983
984    #[test]
985    fn string_rejects_struct_input() {
986        let err =
987            string_builtin(Value::Struct(StructValue::new()), Vec::new()).expect_err("string");
988        assert!(err.contains("structs are not supported"));
989    }
990
991    #[test]
992    fn string_errors_on_unsupported_encoding() {
993        let err = string_builtin(
994            Value::CharArray(CharArray::new_row("abc")),
995            vec![Value::from("UTF-16")],
996        )
997        .unwrap_err();
998        assert!(
999            err.contains("unsupported character encoding"),
1000            "unexpected error message: {err}"
1001        );
1002    }
1003
1004    #[test]
1005    fn string_accepts_system_encoding_alias() {
1006        let out = string_builtin(
1007            Value::CharArray(CharArray::new_row("hello")),
1008            vec![Value::from("system")],
1009        )
1010        .expect("string");
1011        match out {
1012            Value::StringArray(sa) => {
1013                assert_eq!(sa.shape, vec![1, 1]);
1014                assert_eq!(sa.data, vec!["hello"]);
1015            }
1016            other => panic!("expected string array, got {other:?}"),
1017        }
1018    }
1019
1020    #[test]
1021    fn string_encoding_allows_percent_literal() {
1022        let out = string_builtin(
1023            Value::CharArray(CharArray::new_row("100% Done")),
1024            vec![Value::from("utf8")],
1025        )
1026        .expect("string");
1027        match out {
1028            Value::StringArray(sa) => {
1029                assert_eq!(sa.shape, vec![1, 1]);
1030                assert_eq!(sa.data, vec!["100% Done"]);
1031            }
1032            other => panic!("expected string array, got {other:?}"),
1033        }
1034    }
1035
1036    #[test]
1037    fn string_format_spec_cell_requires_text_scalars() {
1038        let cell = CellArray::new(vec![Value::Num(1.0)], 1, 1).expect("cell");
1039        let err = string_builtin(Value::Cell(cell), vec![Value::from("data")]).expect_err("string");
1040        assert!(
1041            err.contains("formatSpec cell elements must be text scalars"),
1042            "unexpected error: {err}"
1043        );
1044    }
1045
1046    #[test]
1047    fn string_format_cell_argument_requires_scalar_values() {
1048        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1049        let cell = CellArray::new(vec![Value::Tensor(tensor)], 1, 1).expect("cell argument values");
1050        let err = string_builtin(Value::from("%d"), vec![Value::Cell(cell)]).expect_err("string");
1051        assert!(err.contains("cell format arguments must contain scalar values"));
1052    }
1053
1054    #[test]
1055    fn string_handles_large_unsigned_int() {
1056        let value = Value::Int(IntValue::U64(u64::MAX));
1057        let out = string_builtin(value, Vec::new()).expect("string");
1058        match out {
1059            Value::StringArray(sa) => {
1060                assert_eq!(sa.shape, vec![1, 1]);
1061                assert_eq!(sa.data, vec![u64::MAX.to_string()]);
1062            }
1063            other => panic!("expected string array, got {other:?}"),
1064        }
1065    }
1066
1067    #[test]
1068    fn string_format_numeric_scalar() {
1069        let out = string_builtin(Value::from("%d"), vec![Value::Num(7.0)]).expect("string");
1070        match out {
1071            Value::StringArray(sa) => {
1072                assert_eq!(sa.shape, vec![1, 1]);
1073                assert_eq!(sa.data, vec!["7"]);
1074            }
1075            other => panic!("expected string array, got {other:?}"),
1076        }
1077    }
1078
1079    #[test]
1080    fn string_format_broadcast_over_tensor() {
1081        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![1, 3]).unwrap();
1082        let out =
1083            string_builtin(Value::from("Trial %d"), vec![Value::Tensor(tensor)]).expect("string");
1084        match out {
1085            Value::StringArray(sa) => {
1086                assert_eq!(sa.shape, vec![1, 3]);
1087                assert_eq!(sa.data, vec!["Trial 1", "Trial 2", "Trial 3"]);
1088            }
1089            other => panic!("expected string array, got {other:?}"),
1090        }
1091    }
1092
1093    #[test]
1094    fn string_format_string_array_spec_alignment() {
1095        let spec = StringArray::new(vec!["[%d]".into(), "Value %d".into()], vec![1, 2]).unwrap();
1096        let tensor = Tensor::new(vec![5.0, 6.0], vec![1, 2]).unwrap();
1097        let out =
1098            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).expect("string");
1099        match out {
1100            Value::StringArray(sa) => {
1101                assert_eq!(sa.shape, vec![1, 2]);
1102                assert_eq!(sa.data, vec!["[5]", "Value 6"]);
1103            }
1104            other => panic!("expected string array, got {other:?}"),
1105        }
1106    }
1107
1108    #[test]
1109    fn string_format_prefers_placeholders_over_encoding_hint() {
1110        let out = string_builtin(Value::from("%s"), vec![Value::from("UTF-8")]).expect("string");
1111        match out {
1112            Value::StringArray(sa) => {
1113                assert_eq!(sa.shape, vec![1, 1]);
1114                assert_eq!(sa.data, vec!["UTF-8"]);
1115            }
1116            other => panic!("expected string array, got {other:?}"),
1117        }
1118    }
1119
1120    #[test]
1121    fn string_format_mismatched_lengths_errors() {
1122        let spec = StringArray::new(vec!["%d".into(), "%d".into()], vec![2, 1]).unwrap();
1123        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1124        let err =
1125            string_builtin(Value::StringArray(spec), vec![Value::Tensor(tensor)]).unwrap_err();
1126        assert!(err.contains("must be scalars or match formatSpec size"));
1127    }
1128
1129    #[test]
1130    fn string_gpu_numeric_tensor() {
1131        test_support::with_test_provider(|provider| {
1132            let tensor = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1133            let view = runmat_accelerate_api::HostTensorView {
1134                data: &tensor.data,
1135                shape: &tensor.shape,
1136            };
1137            let handle = provider.upload(&view).expect("upload");
1138            let result = string_builtin(Value::GpuTensor(handle), Vec::new())
1139                .expect("gpu string conversion");
1140            match result {
1141                Value::StringArray(sa) => {
1142                    assert_eq!(sa.shape, vec![1, 2]);
1143                    assert_eq!(sa.data, vec!["10", "20"]);
1144                }
1145                other => panic!("expected string array, got {other:?}"),
1146            }
1147        });
1148    }
1149
1150    #[test]
1151    #[cfg(feature = "doc_export")]
1152    fn doc_examples_parse() {
1153        let blocks = test_support::doc_examples(DOC_MD);
1154        assert!(!blocks.is_empty());
1155    }
1156
1157    #[test]
1158    #[cfg(feature = "wgpu")]
1159    fn string_wgpu_numeric_tensor_matches_cpu() {
1160        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1161            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1162        );
1163        let tensor = Tensor::new(vec![4.0, 5.0, 6.0], vec![1, 3]).unwrap();
1164        let cpu = string_builtin(Value::Tensor(tensor.clone()), Vec::new())
1165            .expect("cpu string conversion");
1166        let view = runmat_accelerate_api::HostTensorView {
1167            data: &tensor.data,
1168            shape: &tensor.shape,
1169        };
1170        let handle = runmat_accelerate_api::provider()
1171            .unwrap()
1172            .upload(&view)
1173            .expect("gpu upload");
1174        let gpu =
1175            string_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu string conversion");
1176        match (cpu, gpu) {
1177            (Value::StringArray(expect), Value::StringArray(actual)) => {
1178                assert_eq!(actual.shape, expect.shape);
1179                assert_eq!(actual.data, expect.data);
1180            }
1181            other => panic!("unexpected results {other:?}"),
1182        }
1183    }
1184}