runmat_runtime/builtins/strings/core/
num2str.rs

1//! MATLAB-compatible `num2str` builtin with GPU-aware semantics for RunMat.
2
3use 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}