1use 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 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}