1use regex::Regex;
4use runmat_builtins::{CharArray, ComplexTensor, Tensor, Value};
5use runmat_macros::runtime_builtin;
6
7use crate::builtins::common::gpu_helpers;
8use crate::builtins::common::spec::{
9 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
10 ReductionNaN, ResidencyPolicy, ShapeRequirements,
11};
12use crate::builtins::common::tensor;
13#[cfg(feature = "doc_export")]
14use crate::register_builtin_doc_text;
15use crate::{gather_if_needed, register_builtin_fusion_spec, register_builtin_gpu_spec};
16
17const DEFAULT_PRECISION: usize = 15;
18const MAX_PRECISION: usize = 52;
19
20#[cfg(feature = "doc_export")]
21pub const DOC_MD: &str = r#"---
22title: "num2str"
23category: "strings/core"
24keywords: ["num2str", "number to string", "format", "precision", "gpu"]
25summary: "Convert numeric scalars, vectors, and matrices into MATLAB-style character arrays using general or custom formats."
26references:
27 - https://www.mathworks.com/help/matlab/ref/num2str.html
28gpu_support:
29 elementwise: false
30 reduction: false
31 precisions: []
32 broadcasting: "none"
33 notes: "Always formats on the CPU. GPU tensors are gathered to host memory before conversion."
34fusion:
35 elementwise: false
36 reduction: false
37 max_inputs: 1
38 constants: "inline"
39requires_feature: null
40tested:
41 unit: "builtins::strings::core::num2str::tests"
42 integration: "builtins::strings::core::num2str::tests::num2str_gpu_tensor_roundtrip"
43---
44
45# What does the `num2str` function do in MATLAB / RunMat?
46`num2str(x)` converts numeric scalars, vectors, and matrices into a character array where each
47row of `x` becomes a row of text. Values use MATLAB's short-`g` formatting by default, and you can
48provide a precision or an explicit format specifier to control the output. Complex inputs produce
49`a ± bi` strings, and logical data is converted to `0` or `1`.
50
51## How does the `num2str` function behave in MATLAB / RunMat?
52- Default formatting uses up to 15 significant digits with MATLAB-style `g` behaviour (switching to
53 scientific notation when needed).
54- `num2str(x, p)` formats using `p` significant digits (`0 ≤ p ≤ 52`).
55- `num2str(x, fmt)` accepts a single-number `printf`-style format such as `'%0.3f'`, `'%10.4e'`, or
56 `'%.5g'`. Width, `+`, `-`, and `0` flags are supported.
57- A trailing `'local'` argument switches the decimal separator to the one inferred from the active
58 locale (or the `RUNMAT_DECIMAL_SEPARATOR` environment variable).
59- Vector inputs return single-row character arrays; matrices return one textual row per numeric row.
60- Empty matrices return empty character arrays that match MATLAB's dimension rules.
61- Non-numeric types raise MATLAB-compatible errors.
62
63## `num2str` Function GPU Execution Behaviour
64When the input resides on the GPU, RunMat gathers the data back to host memory using the active
65RunMat Accelerate provider before applying the formatting logic. The formatted character array
66always lives on the CPU, so providers do not need to implement specialised kernels.
67
68## Examples of using the `num2str` function in MATLAB / RunMat
69
70### Converting A Scalar With Default Precision
71```matlab
72label = num2str(pi);
73```
74Expected output:
75```matlab
76label =
77 '3.14159265358979'
78```
79
80### Formatting With A Specific Number Of Significant Digits
81```matlab
82digits = num2str(pi, 4);
83```
84Expected output:
85```matlab
86digits =
87 '3.142'
88```
89
90### Using A Custom Format String
91```matlab
92row = num2str([1.234 5.678], '%.2f');
93```
94Expected output:
95```matlab
96row =
97 '1.23 5.68'
98```
99
100### Displaying A Matrix With Column Alignment
101```matlab
102block = num2str([1 23 456; 78 9 10]);
103```
104Expected output:
105```matlab
106block =
107 ' 1 23 456'
108 '78 9 10'
109```
110
111### Formatting Complex Numbers
112```matlab
113z = num2str([3+4i 5-6i]);
114```
115Expected output:
116```matlab
117z =
118 '3 + 4i 5 - 6i'
119```
120
121### Respecting Locale-Specific Decimal Separators
122```matlab
123text = num2str(0.125, 'local');
124```
125On locales that use a comma for decimals:
126```matlab
127text =
128 '0,125'
129```
130
131### Converting GPU-Resident Data
132```matlab
133G = gpuArray([10.5 20.5]);
134txt = num2str(G, '%.1f');
135```
136Expected output:
137```matlab
138txt =
139 '10.5 20.5'
140```
141RunMat gathers the tensor to host memory before formatting.
142
143## FAQ
144
145### Can I request more than 15 digits?
146Yes. Pass a precision between 0 and 52 to control the number of significant digits, e.g.
147`num2str(x, 20)`.
148
149### What format strings are supported?
150RunMat supports single-value `printf` conversions using `%f`, `%e`, `%E`, `%g`, and `%G`, including
151optional width, `+`, `-`, and `0` flags. Unsupported flags raise descriptive errors.
152
153### Does `num2str` alter the size of my array?
154No. The textual result has the same number of rows as the input and aligns each column with spaces.
155
156### How are complex numbers rendered?
157Real and imaginary components are formatted separately using the selected precision. The result is
158`a + bi` or `a - bi`, with zero real parts simplifying to `bi`.
159
160### How does the `'local'` flag work?
161`num2str(..., 'local')` replaces the decimal point with the separator inferred from the active
162locale. You can override the detected character with `RUNMAT_DECIMAL_SEPARATOR`, e.g.
163`RUNMAT_DECIMAL_SEPARATOR=,`.
164
165### What happens with non-numeric inputs?
166Passing structs, objects, handles, or text raises a MATLAB-compatible error. Convert the data to
167numeric form first or use `string` for rich text conversions.
168
169## See Also
170`sprintf`, `string`, `mat2str`, `str2double`
171"#;
172
173pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
174 name: "num2str",
175 op_kind: GpuOpKind::Custom("conversion"),
176 supported_precisions: &[],
177 broadcast: BroadcastSemantics::None,
178 provider_hooks: &[],
179 constant_strategy: ConstantStrategy::InlineLiteral,
180 residency: ResidencyPolicy::GatherImmediately,
181 nan_mode: ReductionNaN::Include,
182 two_pass_threshold: None,
183 workgroup_size: None,
184 accepts_nan_mode: false,
185 notes: "Always gathers GPU data to host memory before formatting numeric text.",
186};
187
188register_builtin_gpu_spec!(GPU_SPEC);
189
190pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
191 name: "num2str",
192 shape: ShapeRequirements::Any,
193 constant_strategy: ConstantStrategy::InlineLiteral,
194 elementwise: None,
195 reduction: None,
196 emits_nan: false,
197 notes:
198 "Conversion builtin; not eligible for fusion and always materialises host character arrays.",
199};
200
201register_builtin_fusion_spec!(FUSION_SPEC);
202
203#[cfg(feature = "doc_export")]
204register_builtin_doc_text!("num2str", DOC_MD);
205
206#[cfg_attr(
207 feature = "doc_export",
208 runtime_builtin(
209 name = "num2str",
210 category = "strings/core",
211 summary = "Format numeric scalars, vectors, and matrices as character arrays.",
212 keywords = "num2str,number,string,format,precision,gpu",
213 accel = "sink"
214 )
215)]
216#[cfg_attr(
217 not(feature = "doc_export"),
218 runtime_builtin(
219 name = "num2str",
220 category = "strings/core",
221 summary = "Format numeric scalars, vectors, and matrices as character arrays.",
222 keywords = "num2str,number,string,format,precision,gpu",
223 accel = "sink"
224 )
225)]
226fn num2str_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
227 let gathered = gather_if_needed(&value).map_err(|e| format!("num2str: {e}"))?;
228 let data = extract_numeric_data(gathered)?;
229
230 let options = parse_options(rest)?;
231 let char_array = format_numeric_data(data, &options)?;
232 Ok(Value::CharArray(char_array))
233}
234
235struct FormatOptions {
236 spec: FormatSpec,
237 decimal: char,
238}
239
240#[derive(Clone)]
241enum FormatSpec {
242 General { digits: usize },
243 Custom(CustomFormat),
244}
245
246#[derive(Clone)]
247struct CustomFormat {
248 kind: CustomKind,
249 width: Option<usize>,
250 precision: Option<usize>,
251 sign_always: bool,
252 left_align: bool,
253 zero_pad: bool,
254 uppercase: bool,
255}
256
257#[derive(Clone, Copy, PartialEq, Eq)]
258enum CustomKind {
259 Fixed,
260 Exponent,
261 General,
262}
263
264enum NumericData {
265 Real {
266 data: Vec<f64>,
267 rows: usize,
268 cols: usize,
269 },
270 Complex {
271 data: Vec<(f64, f64)>,
272 rows: usize,
273 cols: usize,
274 },
275}
276
277fn parse_options(args: Vec<Value>) -> Result<FormatOptions, String> {
278 if args.is_empty() {
279 return Ok(FormatOptions {
280 spec: FormatSpec::General {
281 digits: DEFAULT_PRECISION,
282 },
283 decimal: '.',
284 });
285 }
286
287 let mut gathered = Vec::with_capacity(args.len());
288 for arg in args {
289 gathered.push(gather_if_needed(&arg).map_err(|e| format!("num2str: {e}"))?);
290 }
291
292 let mut iter = gathered.into_iter();
293 let mut spec = FormatSpec::General {
294 digits: DEFAULT_PRECISION,
295 };
296 let mut decimal = '.';
297
298 if let Some(first) = iter.next() {
299 if is_local_token(&first)? {
300 decimal = detect_decimal_separator(true);
301 if iter.next().is_some() {
302 return Err("num2str: too many input arguments".to_string());
303 }
304 return Ok(FormatOptions { spec, decimal });
305 }
306
307 spec = if let Some(digits) = try_extract_precision(&first)? {
308 FormatSpec::General { digits }
309 } else if let Some(text) = value_to_text(&first) {
310 FormatSpec::Custom(parse_custom_format(&text)?)
311 } else {
312 return Err(
313 "num2str: second argument must be a precision or format string".to_string(),
314 );
315 };
316 }
317
318 if let Some(second) = iter.next() {
319 if !is_local_token(&second)? {
320 return Err("num2str: expected 'local' as the third argument".to_string());
321 }
322 decimal = detect_decimal_separator(true);
323 }
324
325 if iter.next().is_some() {
326 return Err("num2str: too many input arguments".to_string());
327 }
328
329 Ok(FormatOptions { spec, decimal })
330}
331
332fn is_local_token(value: &Value) -> Result<bool, String> {
333 let Some(text) = value_to_text(value) else {
334 return Ok(false);
335 };
336 Ok(text.trim().eq_ignore_ascii_case("local"))
337}
338
339fn try_extract_precision(value: &Value) -> Result<Option<usize>, String> {
340 match value {
341 Value::Int(i) => {
342 let digits = i.to_i64();
343 validate_precision(digits)?;
344 Ok(Some(digits as usize))
345 }
346 Value::Num(n) => {
347 if !n.is_finite() {
348 return Err("num2str: precision must be finite".to_string());
349 }
350 let rounded = n.round();
351 if (rounded - n).abs() > f64::EPSILON {
352 return Err("num2str: precision must be an integer".to_string());
353 }
354 validate_precision(rounded as i64)?;
355 Ok(Some(rounded as usize))
356 }
357 Value::Tensor(t) if t.data.len() == 1 => {
358 let value = t.data[0];
359 if !value.is_finite() {
360 return Err("num2str: precision must be finite".to_string());
361 }
362 let rounded = value.round();
363 if (rounded - value).abs() > f64::EPSILON {
364 return Err("num2str: precision must be an integer".to_string());
365 }
366 validate_precision(rounded as i64)?;
367 Ok(Some(rounded as usize))
368 }
369 Value::LogicalArray(la) if la.data.len() == 1 => {
370 let digits = if la.data[0] != 0 { 1 } else { 0 };
371 validate_precision(digits)?;
372 Ok(Some(digits as usize))
373 }
374 Value::Bool(b) => {
375 let digits = if *b { 1 } else { 0 };
376 Ok(Some(digits))
377 }
378 _ => Ok(None),
379 }
380}
381
382fn validate_precision(value: i64) -> Result<(), String> {
383 if value < 0 || value > MAX_PRECISION as i64 {
384 return Err(format!(
385 "num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"
386 ));
387 }
388 Ok(())
389}
390
391fn value_to_text(value: &Value) -> Option<String> {
392 match value {
393 Value::String(s) => Some(s.clone()),
394 Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
395 Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
396 _ => None,
397 }
398}
399
400fn detect_decimal_separator(local: bool) -> char {
401 if !local {
402 return '.';
403 }
404
405 if let Ok(custom) = std::env::var("RUNMAT_DECIMAL_SEPARATOR") {
406 let trimmed = custom.trim();
407 if let Some(ch) = trimmed.chars().next() {
408 return ch;
409 }
410 }
411
412 let locale = std::env::var("LC_NUMERIC")
413 .or_else(|_| std::env::var("RUNMAT_LOCALE"))
414 .or_else(|_| std::env::var("LANG"))
415 .unwrap_or_default()
416 .to_lowercase();
417
418 if locale.is_empty() {
419 return '.';
420 }
421
422 let comma_locales = [
423 "af", "bs", "ca", "cs", "da", "de", "el", "es", "eu", "fi", "fr", "gl", "hr", "hu", "id",
424 "is", "it", "lt", "lv", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sr", "sv", "tr",
425 "uk", "vi",
426 ];
427 let locale_prefix = locale.split(['.', '_', '@']).next().unwrap_or(&locale);
428 for prefix in &comma_locales {
429 if locale_prefix.starts_with(prefix) {
430 return ',';
431 }
432 }
433 '.'
434}
435
436fn parse_custom_format(text: &str) -> Result<CustomFormat, String> {
437 if !text.starts_with('%') {
438 return Err("num2str: format must start with '%'".to_string());
439 }
440 if text == "%%" {
441 return Err("num2str: '%' escape is not supported for numeric conversion".to_string());
442 }
443
444 static FORMAT_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
445 Regex::new(r"^%([+\-0]*)(\d+)?(?:\.(\d*))?([fFeEgG])$").expect("format regex")
446 });
447
448 let captures = FORMAT_RE.captures(text).ok_or_else(|| {
449 "num2str: unsupported format string; expected variants like '%0.3f' or '%.5g'".to_string()
450 })?;
451
452 let flags = captures.get(1).map(|m| m.as_str()).unwrap_or("");
453 let width = captures
454 .get(2)
455 .map(|m| m.as_str().parse::<usize>().expect("width parse"));
456 let precision = captures.get(3).map(|m| {
457 if m.as_str().is_empty() {
458 0usize
459 } else {
460 m.as_str().parse::<usize>().expect("precision parse")
461 }
462 });
463 let conversion = captures
464 .get(4)
465 .map(|m| m.as_str().chars().next().unwrap())
466 .unwrap();
467
468 let mut sign_always = false;
469 let mut left_align = false;
470 let mut zero_pad = false;
471
472 for ch in flags.chars() {
473 match ch {
474 '+' => sign_always = true,
475 '-' => left_align = true,
476 '0' => zero_pad = true,
477 _ => {
478 return Err(format!(
479 "num2str: unsupported format flag '{}'; only '+', '-', and '0' are supported",
480 ch
481 ))
482 }
483 }
484 }
485
486 if let Some(p) = precision {
487 if p > MAX_PRECISION {
488 return Err(format!(
489 "num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"
490 ));
491 }
492 }
493
494 let (kind, uppercase) = match conversion {
495 'f' => (CustomKind::Fixed, false),
496 'F' => (CustomKind::Fixed, true),
497 'e' => (CustomKind::Exponent, false),
498 'E' => (CustomKind::Exponent, true),
499 'g' => (CustomKind::General, false),
500 'G' => (CustomKind::General, true),
501 _ => unreachable!(),
502 };
503
504 Ok(CustomFormat {
505 kind,
506 width,
507 precision,
508 sign_always,
509 left_align,
510 zero_pad,
511 uppercase,
512 })
513}
514
515fn extract_numeric_data(value: Value) -> Result<NumericData, String> {
516 match value {
517 Value::Num(n) => Ok(NumericData::Real {
518 data: vec![n],
519 rows: 1,
520 cols: 1,
521 }),
522 Value::Int(i) => Ok(NumericData::Real {
523 data: vec![i.to_f64()],
524 rows: 1,
525 cols: 1,
526 }),
527 Value::Bool(b) => Ok(NumericData::Real {
528 data: vec![if b { 1.0 } else { 0.0 }],
529 rows: 1,
530 cols: 1,
531 }),
532 Value::Tensor(t) => tensor_to_numeric_data(t),
533 Value::LogicalArray(la) => {
534 let tensor = tensor::logical_to_tensor(&la)?;
535 tensor_to_numeric_data(tensor)
536 }
537 Value::Complex(re, im) => Ok(NumericData::Complex {
538 data: vec![(re, im)],
539 rows: 1,
540 cols: 1,
541 }),
542 Value::ComplexTensor(t) => complex_tensor_to_data(t),
543 Value::GpuTensor(handle) => {
544 let gathered = gpu_helpers::gather_tensor(&handle)?;
545 tensor_to_numeric_data(gathered)
546 }
547 other => Err(format!(
548 "num2str: unsupported input type {:?}; expected numeric or logical values",
549 other
550 )),
551 }
552}
553
554fn tensor_to_numeric_data(tensor: Tensor) -> Result<NumericData, String> {
555 if tensor.shape.len() > 2 {
556 return Err("num2str: input must be scalar, vector, or 2-D matrix".to_string());
557 }
558 let rows = tensor.rows();
559 let cols = tensor.cols();
560 if rows == 0 || cols == 0 {
561 return Ok(NumericData::Real {
562 data: tensor.data,
563 rows,
564 cols,
565 });
566 }
567 Ok(NumericData::Real {
568 data: tensor.data,
569 rows,
570 cols,
571 })
572}
573
574fn complex_tensor_to_data(tensor: ComplexTensor) -> Result<NumericData, String> {
575 if tensor.shape.len() > 2 {
576 return Err("num2str: complex input must be scalar, vector, or 2-D matrix".to_string());
577 }
578 let rows = tensor.rows;
579 let cols = tensor.cols;
580 Ok(NumericData::Complex {
581 data: tensor.data,
582 rows,
583 cols,
584 })
585}
586
587#[derive(Clone)]
588struct CellEntry {
589 text: String,
590 width: usize,
591}
592
593fn format_numeric_data(data: NumericData, options: &FormatOptions) -> Result<CharArray, String> {
594 match data {
595 NumericData::Real { data, rows, cols } => format_real_matrix(&data, rows, cols, options),
596 NumericData::Complex { data, rows, cols } => {
597 format_complex_matrix(&data, rows, cols, options)
598 }
599 }
600}
601
602fn format_real_matrix(
603 data: &[f64],
604 rows: usize,
605 cols: usize,
606 options: &FormatOptions,
607) -> Result<CharArray, String> {
608 if rows == 0 {
609 return CharArray::new(Vec::new(), 0, 0).map_err(|e| format!("num2str: {e}"));
610 }
611 if cols == 0 {
612 return CharArray::new(Vec::new(), rows, 0).map_err(|e| format!("num2str: {e}"));
613 }
614
615 let mut entries = vec![
616 vec![
617 CellEntry {
618 text: String::new(),
619 width: 0
620 };
621 cols
622 ];
623 rows
624 ];
625 let mut col_widths = vec![0usize; cols];
626
627 for (col, width) in col_widths.iter_mut().enumerate() {
628 for (row, row_entries) in entries.iter_mut().enumerate() {
629 let idx = row + col * rows;
630 let value = data.get(idx).copied().unwrap_or(0.0);
631 let text = format_real(value, &options.spec, options.decimal);
632 let entry_width = text.chars().count();
633 row_entries[col] = CellEntry {
634 text,
635 width: entry_width,
636 };
637 if entry_width > *width {
638 *width = entry_width;
639 }
640 }
641 }
642
643 if cols > 1 {
644 for (idx, width) in col_widths.iter_mut().enumerate() {
645 if idx > 0 {
646 *width += 1;
647 }
648 }
649 }
650
651 let rows_str = assemble_rows(entries, col_widths);
652 rows_to_char_array(rows_str)
653}
654
655fn format_complex_matrix(
656 data: &[(f64, f64)],
657 rows: usize,
658 cols: usize,
659 options: &FormatOptions,
660) -> Result<CharArray, String> {
661 if rows == 0 {
662 return CharArray::new(Vec::new(), 0, 0).map_err(|e| format!("num2str: {e}"));
663 }
664 if cols == 0 {
665 return CharArray::new(Vec::new(), rows, 0).map_err(|e| format!("num2str: {e}"));
666 }
667
668 let mut entries = vec![
669 vec![
670 CellEntry {
671 text: String::new(),
672 width: 0
673 };
674 cols
675 ];
676 rows
677 ];
678 let mut col_widths = vec![0usize; cols];
679
680 for (col, width) in col_widths.iter_mut().enumerate() {
681 for (row, row_entries) in entries.iter_mut().enumerate() {
682 let idx = row + col * rows;
683 let (re, im) = data.get(idx).copied().unwrap_or((0.0, 0.0));
684 let text = format_complex(re, im, &options.spec, options.decimal);
685 let entry_width = text.chars().count();
686 row_entries[col] = CellEntry {
687 text,
688 width: entry_width,
689 };
690 if entry_width > *width {
691 *width = entry_width;
692 }
693 }
694 }
695
696 if cols > 1 {
697 for (idx, width) in col_widths.iter_mut().enumerate() {
698 if idx > 0 {
699 *width += 1;
700 }
701 }
702 }
703
704 let rows_str = assemble_rows(entries, col_widths);
705 rows_to_char_array(rows_str)
706}
707
708fn assemble_rows(entries: Vec<Vec<CellEntry>>, col_widths: Vec<usize>) -> Vec<String> {
709 entries
710 .into_iter()
711 .map(|row_entries| {
712 row_entries
713 .into_iter()
714 .enumerate()
715 .fold(String::new(), |mut acc, (col, entry)| {
716 if col > 0 {
717 acc.push(' ');
718 }
719 let target = col_widths[col];
720 let pad = target.saturating_sub(entry.width);
721 acc.extend(std::iter::repeat_n(' ', pad));
722 acc.push_str(&entry.text);
723 acc
724 })
725 })
726 .collect()
727}
728
729fn rows_to_char_array(rows: Vec<String>) -> Result<CharArray, String> {
730 if rows.is_empty() {
731 return CharArray::new(Vec::new(), 0, 0).map_err(|e| format!("num2str: {e}"));
732 }
733 let row_count = rows.len();
734 let col_count = rows
735 .iter()
736 .map(|row| row.chars().count())
737 .max()
738 .unwrap_or(0);
739
740 let mut data = Vec::with_capacity(row_count * col_count);
741 for row in rows {
742 let mut chars: Vec<char> = row.chars().collect();
743 if chars.len() < col_count {
744 chars.extend(std::iter::repeat_n(' ', col_count - chars.len()));
745 }
746 data.extend(chars);
747 }
748
749 CharArray::new(data, row_count, col_count).map_err(|e| format!("num2str: {e}"))
750}
751
752fn format_real(value: f64, spec: &FormatSpec, decimal: char) -> String {
753 let text = match spec {
754 FormatSpec::General { digits } => format_general(value, *digits, false),
755 FormatSpec::Custom(custom) => format_custom(value, custom),
756 };
757 apply_decimal_locale(text, decimal)
758}
759
760fn format_complex(re: f64, im: f64, spec: &FormatSpec, decimal: char) -> String {
761 let real_str = format_real(re, spec, decimal);
762 let imag_sign = if im.is_sign_negative() { '-' } else { '+' };
763 let abs_im = if im == 0.0 { 0.0 } else { im.abs() };
764 let imag_str = format_real(abs_im, spec, decimal);
765
766 if abs_im == 0.0 && !im.is_nan() {
767 return real_str;
768 }
769
770 if re == 0.0 && !re.is_sign_negative() && !re.is_nan() {
771 if im.is_sign_negative() && !im.is_nan() {
772 return format!(
773 "{}i",
774 if imag_str.starts_with('-') {
775 imag_str.clone()
776 } else {
777 format!("-{imag_str}")
778 }
779 );
780 }
781 return format!("{imag_str}i");
782 }
783
784 format!("{real_str} {imag_sign} {imag_str}i")
785}
786
787fn format_general(value: f64, digits: usize, uppercase: bool) -> String {
788 if value.is_nan() {
789 return "NaN".to_string();
790 }
791 if value.is_infinite() {
792 return if value.is_sign_negative() {
793 "-Inf".to_string()
794 } else {
795 "Inf".to_string()
796 };
797 }
798 if value == 0.0 {
799 return "0".to_string();
800 }
801
802 let sig_digits = digits.max(1);
803 let abs_val = value.abs();
804 let exp10 = abs_val.log10().floor() as i32;
805 let use_scientific = exp10 < -4 || exp10 >= sig_digits as i32;
806
807 if use_scientific {
808 let precision = sig_digits.saturating_sub(1);
809 let s = if uppercase {
810 format!("{:.*E}", precision, value)
811 } else {
812 format!("{:.*e}", precision, value)
813 };
814 let marker = if uppercase { 'E' } else { 'e' };
815 if let Some(idx) = s.find(marker) {
816 let (mantissa, exponent) = s.split_at(idx);
817 let mut mant = mantissa.to_string();
818 trim_trailing_zeros(&mut mant);
819 normalize_negative_zero(&mut mant);
820 let mut result = mant;
821 result.push_str(exponent);
822 return result;
823 }
824 s
825 } else {
826 let decimals = if sig_digits as i32 - 1 - exp10 < 0 {
827 0
828 } else {
829 (sig_digits as i32 - 1 - exp10) as usize
830 };
831 let mut s = format!("{:.*}", decimals, value);
832 trim_trailing_zeros(&mut s);
833 normalize_negative_zero(&mut s);
834 s
835 }
836}
837
838fn trim_trailing_zeros(text: &mut String) {
839 if let Some(dot_pos) = text.find('.') {
840 let mut end = text.len();
841 while end > dot_pos + 1 && text.as_bytes()[end - 1] == b'0' {
842 end -= 1;
843 }
844 if end > dot_pos && text.as_bytes()[end - 1] == b'.' {
845 end -= 1;
846 }
847 text.truncate(end);
848 }
849}
850
851fn normalize_negative_zero(text: &mut String) {
852 if text.starts_with('-') && text.chars().skip(1).all(|ch| ch == '0') {
853 *text = "0".to_string();
854 }
855}
856
857fn format_custom(value: f64, fmt: &CustomFormat) -> String {
858 if value.is_nan() {
859 return "NaN".to_string();
860 }
861 if value.is_infinite() {
862 return if value.is_sign_negative() {
863 "-Inf".to_string()
864 } else {
865 "Inf".to_string()
866 };
867 }
868
869 let precision = fmt.precision.unwrap_or(match fmt.kind {
870 CustomKind::Fixed | CustomKind::Exponent => 6,
871 CustomKind::General => DEFAULT_PRECISION,
872 });
873
874 let mut text = match fmt.kind {
875 CustomKind::Fixed => format!("{:.*}", precision, value),
876 CustomKind::Exponent => {
877 let mut s = format!("{:.*e}", precision, value);
878 if fmt.uppercase {
879 s = s.to_uppercase();
880 }
881 s
882 }
883 CustomKind::General => format_general(value, precision.max(1), fmt.uppercase),
884 };
885
886 if fmt.kind != CustomKind::Fixed {
887 trim_trailing_zeros(&mut text);
888 normalize_negative_zero(&mut text);
889 }
890
891 apply_format_flags(text, fmt)
892}
893
894fn apply_decimal_locale(text: String, decimal: char) -> String {
895 if decimal == '.' {
896 return text;
897 }
898 let mut replaced = false;
899 text.chars()
900 .map(|ch| {
901 if ch == '.' && !replaced {
902 replaced = true;
903 decimal
904 } else {
905 ch
906 }
907 })
908 .collect()
909}
910
911fn apply_format_flags(mut text: String, fmt: &CustomFormat) -> String {
912 if fmt.sign_always && !text.starts_with('-') && !text.starts_with('+') && text != "NaN" {
913 text.insert(0, '+');
914 }
915
916 let width = fmt.width.unwrap_or(0);
917 if width == 0 {
918 return text;
919 }
920
921 let len = text.chars().count();
922 if len >= width {
923 return text;
924 }
925
926 let pad_count = width - len;
927 let pad_char = if fmt.zero_pad && !fmt.left_align {
928 '0'
929 } else {
930 ' '
931 };
932
933 if fmt.left_align {
934 let mut result = text.clone();
935 result.extend(std::iter::repeat_n(' ', pad_count));
936 return result;
937 }
938
939 if pad_char == '0' && (text.starts_with('+') || text.starts_with('-')) {
940 let mut chars = text.chars();
941 let sign = chars.next().unwrap();
942 let remainder: String = chars.collect();
943 let mut result = String::with_capacity(width);
944 result.push(sign);
945 result.extend(std::iter::repeat_n('0', pad_count));
946 result.push_str(&remainder);
947 return result;
948 }
949
950 let mut result = String::with_capacity(width);
951 result.extend(std::iter::repeat_n(' ', pad_count));
952 result.push_str(&text);
953 result
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use crate::builtins::common::test_support;
960 use runmat_builtins::{IntValue, LogicalArray, Tensor};
961
962 #[test]
963 fn num2str_scalar_default_precision() {
964 let value = Value::Num(std::f64::consts::PI);
965 let out = num2str_builtin(value, Vec::new()).expect("num2str");
966 match out {
967 Value::CharArray(ca) => {
968 let text: String = ca.data.iter().collect();
969 assert_eq!(ca.rows, 1);
970 assert!(text.starts_with("3.1415926535897"));
971 }
972 other => panic!("expected char array, got {other:?}"),
973 }
974 }
975
976 #[test]
977 fn num2str_precision_argument() {
978 let value = Value::Num(std::f64::consts::PI);
979 let out = num2str_builtin(value, vec![Value::Int(IntValue::I32(4))]).expect("num2str");
980 match out {
981 Value::CharArray(ca) => {
982 let text: String = ca.data.iter().collect();
983 assert_eq!(text.trim(), "3.142");
984 }
985 other => panic!("expected char array, got {other:?}"),
986 }
987 }
988
989 #[test]
990 fn num2str_matrix_alignment() {
991 let tensor =
992 Tensor::new(vec![1.0, 78.0, 23.0, 9.0, 456.0, 10.0], vec![2, 3]).expect("tensor");
993 let out = num2str_builtin(Value::Tensor(tensor), Vec::new()).expect("num2str");
994 match out {
995 Value::CharArray(ca) => {
996 assert_eq!(ca.rows, 2);
997 assert_eq!(ca.cols, 11);
998 let rows: Vec<String> = ca
999 .data
1000 .chunks(ca.cols)
1001 .map(|chunk| chunk.iter().collect())
1002 .collect();
1003 assert_eq!(rows[0], " 1 23 456");
1004 assert_eq!(rows[1], "78 9 10");
1005 }
1006 other => panic!("expected char array, got {other:?}"),
1007 }
1008 }
1009
1010 #[test]
1011 fn num2str_custom_format() {
1012 let tensor = Tensor::new(vec![1.234, 5.678], vec![1, 2]).expect("tensor");
1013 let fmt = Value::String("%.2f".to_string());
1014 let out = num2str_builtin(Value::Tensor(tensor), vec![fmt]).expect("num2str");
1015 match out {
1016 Value::CharArray(ca) => {
1017 let text: String = ca.data.iter().collect();
1018 assert_eq!(text, "1.23 5.68");
1019 }
1020 other => panic!("expected char array, got {other:?}"),
1021 }
1022 }
1023
1024 #[test]
1025 fn num2str_complex_values() {
1026 let complex = ComplexTensor::new(vec![(3.0, 4.0), (5.0, -6.0)], vec![1, 2]).expect("cplx");
1027 let out = num2str_builtin(Value::ComplexTensor(complex), Vec::new()).expect("num2str");
1028 match out {
1029 Value::CharArray(ca) => {
1030 let text: String = ca.data.iter().collect();
1031 assert_eq!(text, "3 + 4i 5 - 6i");
1032 }
1033 other => panic!("expected char array, got {other:?}"),
1034 }
1035 }
1036
1037 #[test]
1038 fn num2str_local_decimal() {
1039 std::env::set_var("RUNMAT_DECIMAL_SEPARATOR", ",");
1040 let out =
1041 num2str_builtin(Value::Num(0.5), vec![Value::String("local".into())]).expect("num2str");
1042 std::env::remove_var("RUNMAT_DECIMAL_SEPARATOR");
1043 match out {
1044 Value::CharArray(ca) => {
1045 let text: String = ca.data.iter().collect();
1046 assert_eq!(text, "0,5");
1047 }
1048 other => panic!("expected char array, got {other:?}"),
1049 }
1050 }
1051
1052 #[test]
1053 fn num2str_logical_array() {
1054 let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).expect("logical");
1055 let out = num2str_builtin(Value::LogicalArray(logical), Vec::new()).expect("num2str");
1056 match out {
1057 Value::CharArray(ca) => {
1058 let text: String = ca.data.iter().collect();
1059 assert_eq!(text, "1 0 1");
1060 }
1061 other => panic!("expected char array, got {other:?}"),
1062 }
1063 }
1064
1065 #[test]
1066 fn num2str_gpu_tensor_roundtrip() {
1067 test_support::with_test_provider(|provider| {
1068 let tensor = Tensor::new(vec![10.5, 20.5], vec![1, 2]).expect("tensor");
1069 let view = runmat_accelerate_api::HostTensorView {
1070 data: &tensor.data,
1071 shape: &tensor.shape,
1072 };
1073 let handle = provider.upload(&view).expect("upload");
1074 let out = num2str_builtin(Value::GpuTensor(handle), vec![Value::String("%.1f".into())])
1075 .expect("num2str");
1076 match out {
1077 Value::CharArray(ca) => {
1078 let text: String = ca.data.iter().collect();
1079 assert_eq!(text, "10.5 20.5");
1080 }
1081 other => panic!("expected char array, got {other:?}"),
1082 }
1083 });
1084 }
1085
1086 #[test]
1087 fn num2str_invalid_input_type() {
1088 let err = num2str_builtin(Value::String("hello".into()), Vec::new()).unwrap_err();
1089 assert!(err.contains("unsupported input type"));
1090 }
1091
1092 #[test]
1093 fn num2str_invalid_format_string() {
1094 let err = num2str_builtin(Value::Num(1.0), vec![Value::String("%q".into())]).unwrap_err();
1095 assert!(err.contains("unsupported format string"));
1096 }
1097
1098 #[test]
1099 #[cfg(feature = "doc_export")]
1100 fn doc_examples_present() {
1101 let blocks = crate::builtins::common::test_support::doc_examples(DOC_MD);
1102 assert!(!blocks.is_empty());
1103 }
1104}