Skip to main content

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::map_control_flow_with_builtin;
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ShapeRequirements,
12};
13use crate::builtins::common::tensor;
14use crate::builtins::strings::type_resolvers::string_scalar_type;
15use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
16
17const DEFAULT_PRECISION: usize = 15;
18const MAX_PRECISION: usize = 52;
19
20fn num2str_flow(message: impl Into<String>) -> RuntimeError {
21    build_runtime_error(message).with_builtin("num2str").build()
22}
23
24fn remap_num2str_flow(err: RuntimeError) -> RuntimeError {
25    map_control_flow_with_builtin(err, "num2str")
26}
27
28#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::num2str")]
29pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
30    name: "num2str",
31    op_kind: GpuOpKind::Custom("conversion"),
32    supported_precisions: &[],
33    broadcast: BroadcastSemantics::None,
34    provider_hooks: &[],
35    constant_strategy: ConstantStrategy::InlineLiteral,
36    residency: ResidencyPolicy::GatherImmediately,
37    nan_mode: ReductionNaN::Include,
38    two_pass_threshold: None,
39    workgroup_size: None,
40    accepts_nan_mode: false,
41    notes: "Always gathers GPU data to host memory before formatting numeric text.",
42};
43
44#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::num2str")]
45pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
46    name: "num2str",
47    shape: ShapeRequirements::Any,
48    constant_strategy: ConstantStrategy::InlineLiteral,
49    elementwise: None,
50    reduction: None,
51    emits_nan: false,
52    notes:
53        "Conversion builtin; not eligible for fusion and always materialises host character arrays.",
54};
55
56#[runtime_builtin(
57    name = "num2str",
58    category = "strings/core",
59    summary = "Convert numeric scalars, vectors, and matrices into MATLAB-style character arrays using general or custom formats.",
60    keywords = "num2str,number to string,format,precision",
61    examples = "txt = num2str([1 2 3]);",
62    type_resolver(string_scalar_type),
63    builtin_path = "crate::builtins::strings::core::num2str"
64)]
65async fn num2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
66    let gathered = gather_if_needed_async(&value)
67        .await
68        .map_err(remap_num2str_flow)?;
69    let data = extract_numeric_data(gathered).await?;
70
71    let options = parse_options(rest).await?;
72    let char_array = format_numeric_data(data, &options)?;
73    Ok(Value::CharArray(char_array))
74}
75
76struct FormatOptions {
77    spec: FormatSpec,
78    decimal: char,
79}
80
81#[derive(Clone)]
82enum FormatSpec {
83    General { digits: usize },
84    Custom(CustomFormat),
85}
86
87#[derive(Clone)]
88struct CustomFormat {
89    kind: CustomKind,
90    width: Option<usize>,
91    precision: Option<usize>,
92    sign_always: bool,
93    left_align: bool,
94    zero_pad: bool,
95    uppercase: bool,
96}
97
98#[derive(Clone, Copy, PartialEq, Eq)]
99enum CustomKind {
100    Fixed,
101    Exponent,
102    General,
103}
104
105enum NumericData {
106    Real {
107        data: Vec<f64>,
108        rows: usize,
109        cols: usize,
110    },
111    Complex {
112        data: Vec<(f64, f64)>,
113        rows: usize,
114        cols: usize,
115    },
116}
117
118async fn parse_options(args: Vec<Value>) -> BuiltinResult<FormatOptions> {
119    if args.is_empty() {
120        return Ok(FormatOptions {
121            spec: FormatSpec::General {
122                digits: DEFAULT_PRECISION,
123            },
124            decimal: '.',
125        });
126    }
127
128    let mut gathered = Vec::with_capacity(args.len());
129    for arg in args {
130        gathered.push(
131            gather_if_needed_async(&arg)
132                .await
133                .map_err(remap_num2str_flow)?,
134        );
135    }
136
137    let mut iter = gathered.into_iter();
138    let mut spec = FormatSpec::General {
139        digits: DEFAULT_PRECISION,
140    };
141    let mut decimal = '.';
142
143    if let Some(first) = iter.next() {
144        if is_local_token(&first)? {
145            decimal = detect_decimal_separator(true);
146            if iter.next().is_some() {
147                return Err(num2str_flow("num2str: too many input arguments"));
148            }
149            return Ok(FormatOptions { spec, decimal });
150        }
151
152        spec = if let Some(digits) = try_extract_precision(&first)? {
153            FormatSpec::General { digits }
154        } else if let Some(text) = value_to_text(&first) {
155            FormatSpec::Custom(parse_custom_format(&text)?)
156        } else {
157            return Err(num2str_flow(
158                "num2str: second argument must be a precision or format string",
159            ));
160        };
161    }
162
163    if let Some(second) = iter.next() {
164        if !is_local_token(&second)? {
165            return Err(num2str_flow(
166                "num2str: expected 'local' as the third argument",
167            ));
168        }
169        decimal = detect_decimal_separator(true);
170    }
171
172    if iter.next().is_some() {
173        return Err(num2str_flow("num2str: too many input arguments"));
174    }
175
176    Ok(FormatOptions { spec, decimal })
177}
178
179fn is_local_token(value: &Value) -> BuiltinResult<bool> {
180    let Some(text) = value_to_text(value) else {
181        return Ok(false);
182    };
183    Ok(text.trim().eq_ignore_ascii_case("local"))
184}
185
186fn try_extract_precision(value: &Value) -> BuiltinResult<Option<usize>> {
187    match value {
188        Value::Int(i) => {
189            let digits = i.to_i64();
190            validate_precision(digits)?;
191            Ok(Some(digits as usize))
192        }
193        Value::Num(n) => {
194            if !n.is_finite() {
195                return Err(num2str_flow("num2str: precision must be finite"));
196            }
197            let rounded = n.round();
198            if (rounded - n).abs() > f64::EPSILON {
199                return Err(num2str_flow("num2str: precision must be an integer"));
200            }
201            validate_precision(rounded as i64)?;
202            Ok(Some(rounded as usize))
203        }
204        Value::Tensor(t) if t.data.len() == 1 => {
205            let value = t.data[0];
206            if !value.is_finite() {
207                return Err(num2str_flow("num2str: precision must be finite"));
208            }
209            let rounded = value.round();
210            if (rounded - value).abs() > f64::EPSILON {
211                return Err(num2str_flow("num2str: precision must be an integer"));
212            }
213            validate_precision(rounded as i64)?;
214            Ok(Some(rounded as usize))
215        }
216        Value::LogicalArray(la) if la.data.len() == 1 => {
217            let digits = if la.data[0] != 0 { 1 } else { 0 };
218            validate_precision(digits)?;
219            Ok(Some(digits as usize))
220        }
221        Value::Bool(b) => {
222            let digits = if *b { 1 } else { 0 };
223            Ok(Some(digits))
224        }
225        _ => Ok(None),
226    }
227}
228
229fn validate_precision(value: i64) -> BuiltinResult<()> {
230    if value < 0 || value > MAX_PRECISION as i64 {
231        return Err(num2str_flow(format!(
232            "num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"
233        )));
234    }
235    Ok(())
236}
237
238fn value_to_text(value: &Value) -> Option<String> {
239    match value {
240        Value::String(s) => Some(s.clone()),
241        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
242        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
243        _ => None,
244    }
245}
246
247fn detect_decimal_separator(local: bool) -> char {
248    if !local {
249        return '.';
250    }
251
252    if let Ok(custom) = std::env::var("RUNMAT_DECIMAL_SEPARATOR") {
253        let trimmed = custom.trim();
254        if let Some(ch) = trimmed.chars().next() {
255            return ch;
256        }
257    }
258
259    let locale = std::env::var("LC_NUMERIC")
260        .or_else(|_| std::env::var("RUNMAT_LOCALE"))
261        .or_else(|_| std::env::var("LANG"))
262        .unwrap_or_default()
263        .to_lowercase();
264
265    if locale.is_empty() {
266        return '.';
267    }
268
269    let comma_locales = [
270        "af", "bs", "ca", "cs", "da", "de", "el", "es", "eu", "fi", "fr", "gl", "hr", "hu", "id",
271        "is", "it", "lt", "lv", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sr", "sv", "tr",
272        "uk", "vi",
273    ];
274    let locale_prefix = locale.split(['.', '_', '@']).next().unwrap_or(&locale);
275    for prefix in &comma_locales {
276        if locale_prefix.starts_with(prefix) {
277            return ',';
278        }
279    }
280    '.'
281}
282
283fn parse_custom_format(text: &str) -> BuiltinResult<CustomFormat> {
284    if !text.starts_with('%') {
285        return Err(num2str_flow("num2str: format must start with '%'"));
286    }
287    if text == "%%" {
288        return Err(num2str_flow(
289            "num2str: '%' escape is not supported for numeric conversion",
290        ));
291    }
292
293    static FORMAT_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
294        Regex::new(r"^%([+\-0]*)(\d+)?(?:\.(\d*))?([fFeEgG])$").expect("format regex")
295    });
296
297    let captures = FORMAT_RE.captures(text).ok_or_else(|| {
298        num2str_flow("num2str: unsupported format string; expected variants like '%0.3f' or '%.5g'")
299    })?;
300
301    let flags = captures.get(1).map(|m| m.as_str()).unwrap_or("");
302    let width = captures
303        .get(2)
304        .map(|m| m.as_str().parse::<usize>().expect("width parse"));
305    let precision = captures.get(3).map(|m| {
306        if m.as_str().is_empty() {
307            0usize
308        } else {
309            m.as_str().parse::<usize>().expect("precision parse")
310        }
311    });
312    let conversion = captures
313        .get(4)
314        .map(|m| m.as_str().chars().next().unwrap())
315        .unwrap();
316
317    let mut sign_always = false;
318    let mut left_align = false;
319    let mut zero_pad = false;
320
321    for ch in flags.chars() {
322        match ch {
323            '+' => sign_always = true,
324            '-' => left_align = true,
325            '0' => zero_pad = true,
326            _ => {
327                return Err(num2str_flow(format!(
328                    "num2str: unsupported format flag '{}'; only '+', '-', and '0' are supported",
329                    ch
330                )))
331            }
332        }
333    }
334
335    if let Some(p) = precision {
336        if p > MAX_PRECISION {
337            return Err(num2str_flow(format!(
338                "num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"
339            )));
340        }
341    }
342
343    let (kind, uppercase) = match conversion {
344        'f' => (CustomKind::Fixed, false),
345        'F' => (CustomKind::Fixed, true),
346        'e' => (CustomKind::Exponent, false),
347        'E' => (CustomKind::Exponent, true),
348        'g' => (CustomKind::General, false),
349        'G' => (CustomKind::General, true),
350        _ => unreachable!(),
351    };
352
353    Ok(CustomFormat {
354        kind,
355        width,
356        precision,
357        sign_always,
358        left_align,
359        zero_pad,
360        uppercase,
361    })
362}
363
364async fn extract_numeric_data(value: Value) -> BuiltinResult<NumericData> {
365    match value {
366        Value::Num(n) => Ok(NumericData::Real {
367            data: vec![n],
368            rows: 1,
369            cols: 1,
370        }),
371        Value::Int(i) => Ok(NumericData::Real {
372            data: vec![i.to_f64()],
373            rows: 1,
374            cols: 1,
375        }),
376        Value::Bool(b) => Ok(NumericData::Real {
377            data: vec![if b { 1.0 } else { 0.0 }],
378            rows: 1,
379            cols: 1,
380        }),
381        Value::Tensor(t) => tensor_to_numeric_data(t),
382        Value::LogicalArray(la) => {
383            let tensor = tensor::logical_to_tensor(&la).map_err(num2str_flow)?;
384            tensor_to_numeric_data(tensor)
385        }
386        Value::Complex(re, im) => Ok(NumericData::Complex {
387            data: vec![(re, im)],
388            rows: 1,
389            cols: 1,
390        }),
391        Value::ComplexTensor(t) => complex_tensor_to_data(t),
392        Value::GpuTensor(handle) => {
393            let gathered = gpu_helpers::gather_tensor_async(&handle)
394                .await
395                .map_err(remap_num2str_flow)?;
396            tensor_to_numeric_data(gathered)
397        }
398        other => Err(num2str_flow(format!(
399            "num2str: unsupported input type {:?}; expected numeric or logical values",
400            other
401        ))),
402    }
403}
404
405fn tensor_to_numeric_data(tensor: Tensor) -> BuiltinResult<NumericData> {
406    if tensor.shape.len() > 2 {
407        return Err(num2str_flow(
408            "num2str: input must be scalar, vector, or 2-D matrix",
409        ));
410    }
411    let rows = tensor.rows();
412    let cols = tensor.cols();
413    if rows == 0 || cols == 0 {
414        return Ok(NumericData::Real {
415            data: tensor.data,
416            rows,
417            cols,
418        });
419    }
420    Ok(NumericData::Real {
421        data: tensor.data,
422        rows,
423        cols,
424    })
425}
426
427fn complex_tensor_to_data(tensor: ComplexTensor) -> BuiltinResult<NumericData> {
428    if tensor.shape.len() > 2 {
429        return Err(num2str_flow(
430            "num2str: complex input must be scalar, vector, or 2-D matrix",
431        ));
432    }
433    let rows = tensor.rows;
434    let cols = tensor.cols;
435    Ok(NumericData::Complex {
436        data: tensor.data,
437        rows,
438        cols,
439    })
440}
441
442#[derive(Clone)]
443struct CellEntry {
444    text: String,
445    width: usize,
446}
447
448fn format_numeric_data(data: NumericData, options: &FormatOptions) -> BuiltinResult<CharArray> {
449    match data {
450        NumericData::Real { data, rows, cols } => format_real_matrix(&data, rows, cols, options),
451        NumericData::Complex { data, rows, cols } => {
452            format_complex_matrix(&data, rows, cols, options)
453        }
454    }
455}
456
457fn format_real_matrix(
458    data: &[f64],
459    rows: usize,
460    cols: usize,
461    options: &FormatOptions,
462) -> BuiltinResult<CharArray> {
463    if rows == 0 {
464        return CharArray::new(Vec::new(), 0, 0).map_err(|e| num2str_flow(format!("num2str: {e}")));
465    }
466    if cols == 0 {
467        return CharArray::new(Vec::new(), rows, 0)
468            .map_err(|e| num2str_flow(format!("num2str: {e}")));
469    }
470
471    let mut entries = vec![
472        vec![
473            CellEntry {
474                text: String::new(),
475                width: 0
476            };
477            cols
478        ];
479        rows
480    ];
481    let mut col_widths = vec![0usize; cols];
482
483    for (col, width) in col_widths.iter_mut().enumerate() {
484        for (row, row_entries) in entries.iter_mut().enumerate() {
485            let idx = row + col * rows;
486            let value = data.get(idx).copied().unwrap_or(0.0);
487            let text = format_real(value, &options.spec, options.decimal);
488            let entry_width = text.chars().count();
489            row_entries[col] = CellEntry {
490                text,
491                width: entry_width,
492            };
493            if entry_width > *width {
494                *width = entry_width;
495            }
496        }
497    }
498
499    if cols > 1 {
500        for (idx, width) in col_widths.iter_mut().enumerate() {
501            if idx > 0 {
502                *width += 1;
503            }
504        }
505    }
506
507    let rows_str = assemble_rows(entries, col_widths);
508    rows_to_char_array(rows_str)
509}
510
511fn format_complex_matrix(
512    data: &[(f64, f64)],
513    rows: usize,
514    cols: usize,
515    options: &FormatOptions,
516) -> BuiltinResult<CharArray> {
517    if rows == 0 {
518        return CharArray::new(Vec::new(), 0, 0).map_err(|e| num2str_flow(format!("num2str: {e}")));
519    }
520    if cols == 0 {
521        return CharArray::new(Vec::new(), rows, 0)
522            .map_err(|e| num2str_flow(format!("num2str: {e}")));
523    }
524
525    let mut entries = vec![
526        vec![
527            CellEntry {
528                text: String::new(),
529                width: 0
530            };
531            cols
532        ];
533        rows
534    ];
535    let mut col_widths = vec![0usize; cols];
536
537    for (col, width) in col_widths.iter_mut().enumerate() {
538        for (row, row_entries) in entries.iter_mut().enumerate() {
539            let idx = row + col * rows;
540            let (re, im) = data.get(idx).copied().unwrap_or((0.0, 0.0));
541            let text = format_complex(re, im, &options.spec, options.decimal);
542            let entry_width = text.chars().count();
543            row_entries[col] = CellEntry {
544                text,
545                width: entry_width,
546            };
547            if entry_width > *width {
548                *width = entry_width;
549            }
550        }
551    }
552
553    if cols > 1 {
554        for (idx, width) in col_widths.iter_mut().enumerate() {
555            if idx > 0 {
556                *width += 1;
557            }
558        }
559    }
560
561    let rows_str = assemble_rows(entries, col_widths);
562    rows_to_char_array(rows_str)
563}
564
565fn assemble_rows(entries: Vec<Vec<CellEntry>>, col_widths: Vec<usize>) -> Vec<String> {
566    entries
567        .into_iter()
568        .map(|row_entries| {
569            row_entries
570                .into_iter()
571                .enumerate()
572                .fold(String::new(), |mut acc, (col, entry)| {
573                    if col > 0 {
574                        acc.push(' ');
575                    }
576                    let target = col_widths[col];
577                    let pad = target.saturating_sub(entry.width);
578                    acc.extend(std::iter::repeat_n(' ', pad));
579                    acc.push_str(&entry.text);
580                    acc
581                })
582        })
583        .collect()
584}
585
586fn rows_to_char_array(rows: Vec<String>) -> BuiltinResult<CharArray> {
587    if rows.is_empty() {
588        return CharArray::new(Vec::new(), 0, 0).map_err(|e| num2str_flow(format!("num2str: {e}")));
589    }
590    let row_count = rows.len();
591    let col_count = rows
592        .iter()
593        .map(|row| row.chars().count())
594        .max()
595        .unwrap_or(0);
596
597    let mut data = Vec::with_capacity(row_count * col_count);
598    for row in rows {
599        let mut chars: Vec<char> = row.chars().collect();
600        if chars.len() < col_count {
601            chars.extend(std::iter::repeat_n(' ', col_count - chars.len()));
602        }
603        data.extend(chars);
604    }
605
606    CharArray::new(data, row_count, col_count).map_err(|e| num2str_flow(format!("num2str: {e}")))
607}
608
609fn format_real(value: f64, spec: &FormatSpec, decimal: char) -> String {
610    let text = match spec {
611        FormatSpec::General { digits } => format_general(value, *digits, false),
612        FormatSpec::Custom(custom) => format_custom(value, custom),
613    };
614    apply_decimal_locale(text, decimal)
615}
616
617fn format_complex(re: f64, im: f64, spec: &FormatSpec, decimal: char) -> String {
618    let real_str = format_real(re, spec, decimal);
619    let imag_sign = if im.is_sign_negative() { '-' } else { '+' };
620    let abs_im = if im == 0.0 { 0.0 } else { im.abs() };
621    let imag_str = format_real(abs_im, spec, decimal);
622
623    if abs_im == 0.0 && !im.is_nan() {
624        return real_str;
625    }
626
627    if re == 0.0 && !re.is_sign_negative() && !re.is_nan() {
628        if im.is_sign_negative() && !im.is_nan() {
629            return format!(
630                "{}i",
631                if imag_str.starts_with('-') {
632                    imag_str.clone()
633                } else {
634                    format!("-{imag_str}")
635                }
636            );
637        }
638        return format!("{imag_str}i");
639    }
640
641    format!("{real_str} {imag_sign} {imag_str}i")
642}
643
644fn format_general(value: f64, digits: usize, uppercase: bool) -> String {
645    if value.is_nan() {
646        return "NaN".to_string();
647    }
648    if value.is_infinite() {
649        return if value.is_sign_negative() {
650            "-Inf".to_string()
651        } else {
652            "Inf".to_string()
653        };
654    }
655    if value == 0.0 {
656        return "0".to_string();
657    }
658
659    let sig_digits = digits.max(1);
660    let abs_val = value.abs();
661    let exp10 = abs_val.log10().floor() as i32;
662    let use_scientific = exp10 < -4 || exp10 >= sig_digits as i32;
663
664    if use_scientific {
665        let precision = sig_digits.saturating_sub(1);
666        let s = if uppercase {
667            format!("{:.*E}", precision, value)
668        } else {
669            format!("{:.*e}", precision, value)
670        };
671        let marker = if uppercase { 'E' } else { 'e' };
672        if let Some(idx) = s.find(marker) {
673            let (mantissa, exponent) = s.split_at(idx);
674            let mut mant = mantissa.to_string();
675            trim_trailing_zeros(&mut mant);
676            normalize_negative_zero(&mut mant);
677            let mut result = mant;
678            result.push_str(exponent);
679            return result;
680        }
681        s
682    } else {
683        let decimals = if sig_digits as i32 - 1 - exp10 < 0 {
684            0
685        } else {
686            (sig_digits as i32 - 1 - exp10) as usize
687        };
688        let mut s = format!("{:.*}", decimals, value);
689        trim_trailing_zeros(&mut s);
690        normalize_negative_zero(&mut s);
691        s
692    }
693}
694
695fn trim_trailing_zeros(text: &mut String) {
696    if let Some(dot_pos) = text.find('.') {
697        let mut end = text.len();
698        while end > dot_pos + 1 && text.as_bytes()[end - 1] == b'0' {
699            end -= 1;
700        }
701        if end > dot_pos && text.as_bytes()[end - 1] == b'.' {
702            end -= 1;
703        }
704        text.truncate(end);
705    }
706}
707
708fn normalize_negative_zero(text: &mut String) {
709    if text.starts_with('-') && text.chars().skip(1).all(|ch| ch == '0') {
710        *text = "0".to_string();
711    }
712}
713
714fn format_custom(value: f64, fmt: &CustomFormat) -> String {
715    if value.is_nan() {
716        return "NaN".to_string();
717    }
718    if value.is_infinite() {
719        return if value.is_sign_negative() {
720            "-Inf".to_string()
721        } else {
722            "Inf".to_string()
723        };
724    }
725
726    let precision = fmt.precision.unwrap_or(match fmt.kind {
727        CustomKind::Fixed | CustomKind::Exponent => 6,
728        CustomKind::General => DEFAULT_PRECISION,
729    });
730
731    let mut text = match fmt.kind {
732        CustomKind::Fixed => format!("{:.*}", precision, value),
733        CustomKind::Exponent => {
734            let mut s = format!("{:.*e}", precision, value);
735            if fmt.uppercase {
736                s = s.to_uppercase();
737            }
738            s
739        }
740        CustomKind::General => format_general(value, precision.max(1), fmt.uppercase),
741    };
742
743    if fmt.kind != CustomKind::Fixed {
744        trim_trailing_zeros(&mut text);
745        normalize_negative_zero(&mut text);
746    }
747
748    apply_format_flags(text, fmt)
749}
750
751fn apply_decimal_locale(text: String, decimal: char) -> String {
752    if decimal == '.' {
753        return text;
754    }
755    let mut replaced = false;
756    text.chars()
757        .map(|ch| {
758            if ch == '.' && !replaced {
759                replaced = true;
760                decimal
761            } else {
762                ch
763            }
764        })
765        .collect()
766}
767
768fn apply_format_flags(mut text: String, fmt: &CustomFormat) -> String {
769    if fmt.sign_always && !text.starts_with('-') && !text.starts_with('+') && text != "NaN" {
770        text.insert(0, '+');
771    }
772
773    let width = fmt.width.unwrap_or(0);
774    if width == 0 {
775        return text;
776    }
777
778    let len = text.chars().count();
779    if len >= width {
780        return text;
781    }
782
783    let pad_count = width - len;
784    let pad_char = if fmt.zero_pad && !fmt.left_align {
785        '0'
786    } else {
787        ' '
788    };
789
790    if fmt.left_align {
791        let mut result = text.clone();
792        result.extend(std::iter::repeat_n(' ', pad_count));
793        return result;
794    }
795
796    if pad_char == '0' && (text.starts_with('+') || text.starts_with('-')) {
797        let mut chars = text.chars();
798        let sign = chars.next().unwrap();
799        let remainder: String = chars.collect();
800        let mut result = String::with_capacity(width);
801        result.push(sign);
802        result.extend(std::iter::repeat_n('0', pad_count));
803        result.push_str(&remainder);
804        return result;
805    }
806
807    let mut result = String::with_capacity(width);
808    result.extend(std::iter::repeat_n(' ', pad_count));
809    result.push_str(&text);
810    result
811}
812
813#[cfg(test)]
814pub(crate) mod tests {
815    use super::*;
816    use crate::builtins::common::test_support;
817    use runmat_builtins::{ResolveContext, Type};
818
819    fn num2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
820        futures::executor::block_on(super::num2str_builtin(value, rest))
821    }
822    use runmat_builtins::{IntValue, LogicalArray, Tensor};
823
824    fn error_message(err: crate::RuntimeError) -> String {
825        err.message().to_string()
826    }
827
828    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
829    #[test]
830    fn num2str_scalar_default_precision() {
831        let value = Value::Num(std::f64::consts::PI);
832        let out = num2str_builtin(value, Vec::new()).expect("num2str");
833        match out {
834            Value::CharArray(ca) => {
835                let text: String = ca.data.iter().collect();
836                assert_eq!(ca.rows, 1);
837                assert!(text.starts_with("3.1415926535897"));
838            }
839            other => panic!("expected char array, got {other:?}"),
840        }
841    }
842
843    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
844    #[test]
845    fn num2str_precision_argument() {
846        let value = Value::Num(std::f64::consts::PI);
847        let out = num2str_builtin(value, vec![Value::Int(IntValue::I32(4))]).expect("num2str");
848        match out {
849            Value::CharArray(ca) => {
850                let text: String = ca.data.iter().collect();
851                assert_eq!(text.trim(), "3.142");
852            }
853            other => panic!("expected char array, got {other:?}"),
854        }
855    }
856
857    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
858    #[test]
859    fn num2str_matrix_alignment() {
860        let tensor =
861            Tensor::new(vec![1.0, 78.0, 23.0, 9.0, 456.0, 10.0], vec![2, 3]).expect("tensor");
862        let out = num2str_builtin(Value::Tensor(tensor), Vec::new()).expect("num2str");
863        match out {
864            Value::CharArray(ca) => {
865                assert_eq!(ca.rows, 2);
866                assert_eq!(ca.cols, 11);
867                let rows: Vec<String> = ca
868                    .data
869                    .chunks(ca.cols)
870                    .map(|chunk| chunk.iter().collect())
871                    .collect();
872                assert_eq!(rows[0], " 1  23  456");
873                assert_eq!(rows[1], "78   9   10");
874            }
875            other => panic!("expected char array, got {other:?}"),
876        }
877    }
878
879    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
880    #[test]
881    fn num2str_custom_format() {
882        let tensor = Tensor::new(vec![1.234, 5.678], vec![1, 2]).expect("tensor");
883        let fmt = Value::String("%.2f".to_string());
884        let out = num2str_builtin(Value::Tensor(tensor), vec![fmt]).expect("num2str");
885        match out {
886            Value::CharArray(ca) => {
887                let text: String = ca.data.iter().collect();
888                assert_eq!(text, "1.23  5.68");
889            }
890            other => panic!("expected char array, got {other:?}"),
891        }
892    }
893
894    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
895    #[test]
896    fn num2str_complex_values() {
897        let complex = ComplexTensor::new(vec![(3.0, 4.0), (5.0, -6.0)], vec![1, 2]).expect("cplx");
898        let out = num2str_builtin(Value::ComplexTensor(complex), Vec::new()).expect("num2str");
899        match out {
900            Value::CharArray(ca) => {
901                let text: String = ca.data.iter().collect();
902                assert_eq!(text, "3 + 4i  5 - 6i");
903            }
904            other => panic!("expected char array, got {other:?}"),
905        }
906    }
907
908    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
909    #[test]
910    fn num2str_local_decimal() {
911        std::env::set_var("RUNMAT_DECIMAL_SEPARATOR", ",");
912        let out =
913            num2str_builtin(Value::Num(0.5), vec![Value::String("local".into())]).expect("num2str");
914        std::env::remove_var("RUNMAT_DECIMAL_SEPARATOR");
915        match out {
916            Value::CharArray(ca) => {
917                let text: String = ca.data.iter().collect();
918                assert_eq!(text, "0,5");
919            }
920            other => panic!("expected char array, got {other:?}"),
921        }
922    }
923
924    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
925    #[test]
926    fn num2str_logical_array() {
927        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).expect("logical");
928        let out = num2str_builtin(Value::LogicalArray(logical), Vec::new()).expect("num2str");
929        match out {
930            Value::CharArray(ca) => {
931                let text: String = ca.data.iter().collect();
932                assert_eq!(text, "1  0  1");
933            }
934            other => panic!("expected char array, got {other:?}"),
935        }
936    }
937
938    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
939    #[test]
940    fn num2str_gpu_tensor_roundtrip() {
941        test_support::with_test_provider(|provider| {
942            let tensor = Tensor::new(vec![10.5, 20.5], vec![1, 2]).expect("tensor");
943            let view = runmat_accelerate_api::HostTensorView {
944                data: &tensor.data,
945                shape: &tensor.shape,
946            };
947            let handle = provider.upload(&view).expect("upload");
948            let out = num2str_builtin(Value::GpuTensor(handle), vec![Value::String("%.1f".into())])
949                .expect("num2str");
950            match out {
951                Value::CharArray(ca) => {
952                    let text: String = ca.data.iter().collect();
953                    assert_eq!(text, "10.5  20.5");
954                }
955                other => panic!("expected char array, got {other:?}"),
956            }
957        });
958    }
959
960    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
961    #[test]
962    fn num2str_invalid_input_type() {
963        let err =
964            error_message(num2str_builtin(Value::String("hello".into()), Vec::new()).unwrap_err());
965        assert!(err.contains("unsupported input type"));
966    }
967
968    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
969    #[test]
970    fn num2str_invalid_format_string() {
971        let err = error_message(
972            num2str_builtin(Value::Num(1.0), vec![Value::String("%q".into())]).unwrap_err(),
973        );
974        assert!(err.contains("unsupported format string"));
975    }
976
977    #[test]
978    fn num2str_type_is_string_scalar() {
979        assert_eq!(
980            string_scalar_type(&[Type::Num], &ResolveContext::new(Vec::new())),
981            Type::String
982        );
983    }
984}