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::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
6    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
7    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
8    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
9    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11};
12use runmat_macros::runtime_builtin;
13use runmat_value::{
14    CharArray, ComplexTensor, IntValue, IntegerComplexStorage, IntegerStorage, Tensor, Value,
15};
16
17use crate::builtins::common::gpu_helpers;
18use crate::builtins::common::map_control_flow_with_builtin;
19use crate::builtins::common::spec::{
20    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
21    ReductionNaN, ResidencyPolicy, ShapeRequirements,
22};
23use crate::builtins::common::tensor;
24use crate::builtins::strings::type_resolvers::string_scalar_type;
25use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
26
27const DEFAULT_PRECISION: usize = 15;
28const MAX_PRECISION: usize = 52;
29
30const BUILTIN_NAME: &str = "num2str";
31
32const NUM2STR_EXPLICIT_GPU_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
33    id: "num2str-explicit-gpu-input",
34    mode: BuiltinExtensionMode::RunMatOnly,
35    description: "num2str host formatting for explicit gpuArray input is a RunMat extension",
36    error_identifier: Some("RunMat:compatibility:Num2strExplicitGpuInputExtension"),
37};
38const NUM2STR_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [NUM2STR_EXPLICIT_GPU_EXTENSION];
39
40const NUM2STR_INTEGER_VALUE_INPUTS: [BuiltinIntegerInputCapability; 1] =
41    [BuiltinIntegerInputCapability {
42        name: "A",
43        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
44        availability: BuiltinIntegerInputAvailability::Documented,
45        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
46        notes: "All eight integer classes are formatted directly from authoritative native storage; paired complex-integer components retain exact decimal text as well.",
47    }];
48const NUM2STR_INTEGER_PRECISION_INPUTS: [BuiltinIntegerInputCapability; 1] =
49    [BuiltinIntegerInputCapability {
50        name: "p",
51        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
52        availability: BuiltinIntegerInputAvailability::Documented,
53        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
54        notes: "The precision control accepts every integer class and is range checked exactly before conversion to a bounded host usize.",
55    }];
56pub const NUM2STR_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
57    BuiltinIntegerCapabilityDescriptor {
58        form: "txt = num2str(integer_A, ...)",
59        inputs: &NUM2STR_INTEGER_VALUE_INPUTS,
60        computation_domain: BuiltinIntegerComputationDomain::ExactInteger,
61        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
62        overflow: BuiltinIntegerOverflowRule::NotApplicable,
63        backend: BuiltinIntegerBackendRule::GatherFallback,
64        overload: BuiltinIntegerOverloadKind::Multiple,
65        notes: "Default and custom formatting preserve exact signed and unsigned decimal values, including values above flintmax. Automatic residency gathers through its owner; explicit gpuArray fallback is separately gated.",
66    },
67    BuiltinIntegerCapabilityDescriptor {
68        form: "txt = num2str(A, integer_p[, \"local\"])",
69        inputs: &NUM2STR_INTEGER_PRECISION_INPUTS,
70        computation_domain: BuiltinIntegerComputationDomain::Structural,
71        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
72        overflow: BuiltinIntegerOverflowRule::Error,
73        backend: BuiltinIntegerBackendRule::GatherFallback,
74        overload: BuiltinIntegerOverloadKind::ScalarOnly,
75        notes: "Precision is accepted only in the supported bounded range and does not determine output numeric storage.",
76    },
77];
78
79const NUM2STR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
80    name: "txt",
81    ty: BuiltinParamType::Any,
82    arity: BuiltinParamArity::Required,
83    default: None,
84    description: "Character array containing formatted numeric values.",
85}];
86
87const NUM2STR_INPUT_A: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
88    name: "A",
89    ty: BuiltinParamType::Any,
90    arity: BuiltinParamArity::Required,
91    default: None,
92    description: "Numeric or logical scalar/vector/matrix input.",
93}];
94
95const NUM2STR_INPUT_PREC: [BuiltinParamDescriptor; 2] = [
96    BuiltinParamDescriptor {
97        name: "A",
98        ty: BuiltinParamType::Any,
99        arity: BuiltinParamArity::Required,
100        default: None,
101        description: "Numeric or logical input.",
102    },
103    BuiltinParamDescriptor {
104        name: "p",
105        ty: BuiltinParamType::IntegerScalar,
106        arity: BuiltinParamArity::Required,
107        default: Some("15"),
108        description: "General-format precision (0..52).",
109    },
110];
111
112const NUM2STR_INPUT_FORMAT: [BuiltinParamDescriptor; 2] = [
113    BuiltinParamDescriptor {
114        name: "A",
115        ty: BuiltinParamType::Any,
116        arity: BuiltinParamArity::Required,
117        default: None,
118        description: "Numeric or logical input.",
119    },
120    BuiltinParamDescriptor {
121        name: "formatSpec",
122        ty: BuiltinParamType::StringScalar,
123        arity: BuiltinParamArity::Required,
124        default: None,
125        description: "Custom format such as \"%0.3f\" or \"%.5g\".",
126    },
127];
128
129const NUM2STR_INPUT_LOCAL: [BuiltinParamDescriptor; 3] = [
130    BuiltinParamDescriptor {
131        name: "A",
132        ty: BuiltinParamType::Any,
133        arity: BuiltinParamArity::Required,
134        default: None,
135        description: "Numeric or logical input.",
136    },
137    BuiltinParamDescriptor {
138        name: "arg2",
139        ty: BuiltinParamType::Any,
140        arity: BuiltinParamArity::Optional,
141        default: None,
142        description: "Precision or format string.",
143    },
144    BuiltinParamDescriptor {
145        name: "local",
146        ty: BuiltinParamType::StringScalar,
147        arity: BuiltinParamArity::Required,
148        default: Some("\"local\""),
149        description: "Locale-aware decimal separator option.",
150    },
151];
152
153const NUM2STR_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
154    BuiltinSignatureDescriptor {
155        label: "txt = num2str(A)",
156        inputs: &NUM2STR_INPUT_A,
157        outputs: &NUM2STR_OUTPUT,
158    },
159    BuiltinSignatureDescriptor {
160        label: "txt = num2str(A, p)",
161        inputs: &NUM2STR_INPUT_PREC,
162        outputs: &NUM2STR_OUTPUT,
163    },
164    BuiltinSignatureDescriptor {
165        label: "txt = num2str(A, formatSpec)",
166        inputs: &NUM2STR_INPUT_FORMAT,
167        outputs: &NUM2STR_OUTPUT,
168    },
169    BuiltinSignatureDescriptor {
170        label: "txt = num2str(A, arg2, \"local\")",
171        inputs: &NUM2STR_INPUT_LOCAL,
172        outputs: &NUM2STR_OUTPUT,
173    },
174];
175
176const NUM2STR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
177    code: "RM.NUM2STR.INVALID_INPUT",
178    identifier: Some("RunMat:num2str:InvalidInput"),
179    when: "Input value is not a supported numeric/logical scalar, vector, or matrix.",
180    message: "num2str: unsupported input type",
181};
182
183const NUM2STR_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
184    code: "RM.NUM2STR.INVALID_OPTION",
185    identifier: Some("RunMat:num2str:InvalidOption"),
186    when: "Optional arguments are malformed or too many were supplied.",
187    message: "num2str: invalid option arguments",
188};
189
190const NUM2STR_ERROR_INVALID_PRECISION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
191    code: "RM.NUM2STR.INVALID_PRECISION",
192    identifier: Some("RunMat:num2str:InvalidPrecision"),
193    when: "Precision argument is non-finite, non-integer, or out of range.",
194    message: "num2str: invalid precision",
195};
196
197const NUM2STR_ERROR_INVALID_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
198    code: "RM.NUM2STR.INVALID_FORMAT",
199    identifier: Some("RunMat:num2str:InvalidFormat"),
200    when: "Custom format string is unsupported or malformed.",
201    message: "num2str: unsupported format string",
202};
203
204const NUM2STR_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
205    code: "RM.NUM2STR.INTERNAL",
206    identifier: Some("RunMat:num2str:InternalError"),
207    when: "Internal char-array assembly failed.",
208    message: "num2str: internal error",
209};
210
211const NUM2STR_ERRORS: [BuiltinErrorDescriptor; 5] = [
212    NUM2STR_ERROR_INVALID_INPUT,
213    NUM2STR_ERROR_INVALID_OPTION,
214    NUM2STR_ERROR_INVALID_PRECISION,
215    NUM2STR_ERROR_INVALID_FORMAT,
216    NUM2STR_ERROR_INTERNAL,
217];
218
219pub const NUM2STR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
220    signatures: &NUM2STR_SIGNATURES,
221    output_mode: BuiltinOutputMode::Fixed,
222    completion_policy: BuiltinCompletionPolicy::Public,
223    errors: &NUM2STR_ERRORS,
224};
225
226fn num2str_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
227    num2str_error_with_message(error.message, error)
228}
229
230fn num2str_error_with_message(
231    message: impl Into<String>,
232    error: &'static BuiltinErrorDescriptor,
233) -> RuntimeError {
234    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
235    if let Some(identifier) = error.identifier {
236        builder = builder.with_identifier(identifier);
237    }
238    builder.build()
239}
240
241fn remap_num2str_flow(err: RuntimeError) -> RuntimeError {
242    map_control_flow_with_builtin(err, BUILTIN_NAME)
243}
244
245#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::num2str")]
246pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
247    name: "num2str",
248    op_kind: GpuOpKind::Custom("conversion"),
249    supported_precisions: &[],
250    broadcast: BroadcastSemantics::None,
251    provider_hooks: &[],
252    constant_strategy: ConstantStrategy::InlineLiteral,
253    residency: ResidencyPolicy::GatherImmediately,
254    nan_mode: ReductionNaN::Include,
255    two_pass_threshold: None,
256    workgroup_size: None,
257    accepts_nan_mode: false,
258    notes: "Always gathers GPU data to host memory before formatting numeric text.",
259};
260
261#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::num2str")]
262pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
263    name: "num2str",
264    shape: ShapeRequirements::Any,
265    constant_strategy: ConstantStrategy::InlineLiteral,
266    elementwise: None,
267    reduction: None,
268    emits_nan: false,
269    notes:
270        "Conversion builtin; not eligible for fusion and always materialises host character arrays.",
271};
272
273#[runtime_builtin(
274    name = "num2str",
275    category = "strings/core",
276    summary = "Convert numeric values to character arrays.",
277    keywords = "num2str,number to string,format,precision",
278    examples = "txt = num2str([1 2 3]);",
279    type_resolver(string_scalar_type),
280    extensions(NUM2STR_EXTENSIONS),
281    integer_capabilities(NUM2STR_INTEGER_CAPABILITIES),
282    descriptor(crate::builtins::strings::core::num2str::NUM2STR_DESCRIPTOR),
283    builtin_path = "crate::builtins::strings::core::num2str"
284)]
285async fn num2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
286    if crate::builtins::common::validation::value_contains_explicit_gpu(&value)
287        || rest
288            .iter()
289            .any(crate::builtins::common::validation::value_contains_explicit_gpu)
290    {
291        crate::compatibility::ensure_builtin_extension_enabled(
292            &NUM2STR_EXPLICIT_GPU_EXTENSION,
293            BUILTIN_NAME,
294        )?;
295    }
296    let gathered = gather_if_needed_async(&value)
297        .await
298        .map_err(remap_num2str_flow)?;
299    let data = extract_numeric_data(gathered).await?;
300
301    let options = parse_options(rest).await?;
302    let char_array = format_numeric_data(data, &options)?;
303    Ok(Value::CharArray(char_array))
304}
305
306struct FormatOptions {
307    spec: FormatSpec,
308    decimal: char,
309}
310
311#[derive(Clone)]
312enum FormatSpec {
313    General { digits: usize },
314    Custom(CustomFormat),
315}
316
317#[derive(Clone)]
318struct CustomFormat {
319    kind: CustomKind,
320    width: Option<usize>,
321    precision: Option<usize>,
322    sign_always: bool,
323    left_align: bool,
324    zero_pad: bool,
325    uppercase: bool,
326}
327
328#[derive(Clone, Copy, PartialEq, Eq)]
329enum CustomKind {
330    Fixed,
331    Exponent,
332    General,
333}
334
335enum NumericData {
336    Real {
337        data: Vec<f64>,
338        rows: usize,
339        cols: usize,
340    },
341    Complex {
342        data: Vec<(f64, f64)>,
343        rows: usize,
344        cols: usize,
345    },
346    IntegerComplex {
347        storage: IntegerComplexStorage,
348        rows: usize,
349        cols: usize,
350    },
351    Integer {
352        storage: IntegerStorage,
353        rows: usize,
354        cols: usize,
355    },
356}
357
358async fn parse_options(args: Vec<Value>) -> BuiltinResult<FormatOptions> {
359    if args.is_empty() {
360        return Ok(FormatOptions {
361            spec: FormatSpec::General {
362                digits: DEFAULT_PRECISION,
363            },
364            decimal: '.',
365        });
366    }
367
368    let mut gathered = Vec::with_capacity(args.len());
369    for arg in args {
370        gathered.push(
371            gather_if_needed_async(&arg)
372                .await
373                .map_err(remap_num2str_flow)?,
374        );
375    }
376
377    let mut iter = gathered.into_iter();
378    let mut spec = FormatSpec::General {
379        digits: DEFAULT_PRECISION,
380    };
381    let mut decimal = '.';
382
383    if let Some(first) = iter.next() {
384        if is_local_token(&first)? {
385            decimal = detect_decimal_separator(true);
386            if iter.next().is_some() {
387                return Err(num2str_error_with_message(
388                    "num2str: too many input arguments",
389                    &NUM2STR_ERROR_INVALID_OPTION,
390                ));
391            }
392            return Ok(FormatOptions { spec, decimal });
393        }
394
395        spec = if let Some(digits) = try_extract_precision(&first)? {
396            FormatSpec::General { digits }
397        } else if let Some(text) = value_to_text(&first) {
398            FormatSpec::Custom(parse_custom_format(&text)?)
399        } else {
400            return Err(num2str_error_with_message(
401                "num2str: second argument must be a precision or format string",
402                &NUM2STR_ERROR_INVALID_OPTION,
403            ));
404        };
405    }
406
407    if let Some(second) = iter.next() {
408        if !is_local_token(&second)? {
409            return Err(num2str_error_with_message(
410                "num2str: expected 'local' as the third argument",
411                &NUM2STR_ERROR_INVALID_OPTION,
412            ));
413        }
414        decimal = detect_decimal_separator(true);
415    }
416
417    if iter.next().is_some() {
418        return Err(num2str_error_with_message(
419            "num2str: too many input arguments",
420            &NUM2STR_ERROR_INVALID_OPTION,
421        ));
422    }
423
424    Ok(FormatOptions { spec, decimal })
425}
426
427fn is_local_token(value: &Value) -> BuiltinResult<bool> {
428    let Some(text) = value_to_text(value) else {
429        return Ok(false);
430    };
431    Ok(text.trim().eq_ignore_ascii_case("local"))
432}
433
434fn try_extract_precision(value: &Value) -> BuiltinResult<Option<usize>> {
435    match value {
436        Value::Int(i) => {
437            let digits = i.try_to_i64().ok_or_else(|| {
438                num2str_error_with_message(
439                    format!("num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"),
440                    &NUM2STR_ERROR_INVALID_PRECISION,
441                )
442            })?;
443            validate_precision(digits)?;
444            Ok(Some(digits as usize))
445        }
446        Value::Num(n) => {
447            if !n.is_finite() {
448                return Err(num2str_error_with_message(
449                    "num2str: precision must be finite",
450                    &NUM2STR_ERROR_INVALID_PRECISION,
451                ));
452            }
453            let rounded = n.round();
454            if (rounded - n).abs() > f64::EPSILON {
455                return Err(num2str_error_with_message(
456                    "num2str: precision must be an integer",
457                    &NUM2STR_ERROR_INVALID_PRECISION,
458                ));
459            }
460            validate_precision(rounded as i64)?;
461            Ok(Some(rounded as usize))
462        }
463        Value::Tensor(t) if tensor::is_scalar_tensor(t) => {
464            if let Some(int) = t.integer_storage().and_then(|storage| storage.value_at(0)) {
465                let digits = int.try_to_i64().ok_or_else(|| {
466                    num2str_error_with_message(
467                        format!("num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"),
468                        &NUM2STR_ERROR_INVALID_PRECISION,
469                    )
470                })?;
471                validate_precision(digits)?;
472                return Ok(Some(digits as usize));
473            }
474            let value = tensor::tensor_value_f64(t, 0);
475            if !value.is_finite() {
476                return Err(num2str_error_with_message(
477                    "num2str: precision must be finite",
478                    &NUM2STR_ERROR_INVALID_PRECISION,
479                ));
480            }
481            let rounded = value.round();
482            if (rounded - value).abs() > f64::EPSILON {
483                return Err(num2str_error_with_message(
484                    "num2str: precision must be an integer",
485                    &NUM2STR_ERROR_INVALID_PRECISION,
486                ));
487            }
488            validate_precision(rounded as i64)?;
489            Ok(Some(rounded as usize))
490        }
491        Value::LogicalArray(la) if la.data.len() == 1 => {
492            let digits = if la.data[0] != 0 { 1 } else { 0 };
493            validate_precision(digits)?;
494            Ok(Some(digits as usize))
495        }
496        Value::Bool(b) => {
497            let digits = if *b { 1 } else { 0 };
498            Ok(Some(digits))
499        }
500        _ => Ok(None),
501    }
502}
503
504fn validate_precision(value: i64) -> BuiltinResult<()> {
505    if value < 0 || value > MAX_PRECISION as i64 {
506        return Err(num2str_error_with_message(
507            format!("num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"),
508            &NUM2STR_ERROR_INVALID_PRECISION,
509        ));
510    }
511    Ok(())
512}
513
514fn value_to_text(value: &Value) -> Option<String> {
515    match value {
516        Value::String(s) => Some(s.clone()),
517        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
518        Value::CharArray(ca) if ca.rows == 1 => Some(ca.data.iter().collect()),
519        _ => None,
520    }
521}
522
523fn detect_decimal_separator(local: bool) -> char {
524    if !local {
525        return '.';
526    }
527
528    if let Ok(custom) = std::env::var("RUNMAT_DECIMAL_SEPARATOR") {
529        let trimmed = custom.trim();
530        if let Some(ch) = trimmed.chars().next() {
531            return ch;
532        }
533    }
534
535    let locale = std::env::var("LC_NUMERIC")
536        .or_else(|_| std::env::var("RUNMAT_LOCALE"))
537        .or_else(|_| std::env::var("LANG"))
538        .unwrap_or_default()
539        .to_lowercase();
540
541    if locale.is_empty() {
542        return '.';
543    }
544
545    let comma_locales = [
546        "af", "bs", "ca", "cs", "da", "de", "el", "es", "eu", "fi", "fr", "gl", "hr", "hu", "id",
547        "is", "it", "lt", "lv", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sr", "sv", "tr",
548        "uk", "vi",
549    ];
550    let locale_prefix = locale.split(['.', '_', '@']).next().unwrap_or(&locale);
551    for prefix in &comma_locales {
552        if locale_prefix.starts_with(prefix) {
553            return ',';
554        }
555    }
556    '.'
557}
558
559fn parse_custom_format(text: &str) -> BuiltinResult<CustomFormat> {
560    if !text.starts_with('%') {
561        return Err(num2str_error_with_message(
562            "num2str: format must start with '%'",
563            &NUM2STR_ERROR_INVALID_FORMAT,
564        ));
565    }
566    if text == "%%" {
567        return Err(num2str_error_with_message(
568            "num2str: '%' escape is not supported for numeric conversion",
569            &NUM2STR_ERROR_INVALID_FORMAT,
570        ));
571    }
572
573    static FORMAT_RE: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(|| {
574        Regex::new(r"^%([+\-0]*)(\d+)?(?:\.(\d*))?([fFeEgG])$").expect("format regex")
575    });
576
577    let captures = FORMAT_RE.captures(text).ok_or_else(|| {
578        num2str_error_with_message(
579            format!(
580                "{}; expected variants like '%0.3f' or '%.5g'",
581                NUM2STR_ERROR_INVALID_FORMAT.message
582            ),
583            &NUM2STR_ERROR_INVALID_FORMAT,
584        )
585    })?;
586
587    let flags = captures.get(1).map(|m| m.as_str()).unwrap_or("");
588    let width = captures
589        .get(2)
590        .map(|m| m.as_str().parse::<usize>().expect("width parse"));
591    let precision = captures.get(3).map(|m| {
592        if m.as_str().is_empty() {
593            0usize
594        } else {
595            m.as_str().parse::<usize>().expect("precision parse")
596        }
597    });
598    let conversion = captures
599        .get(4)
600        .map(|m| m.as_str().chars().next().unwrap())
601        .unwrap();
602
603    let mut sign_always = false;
604    let mut left_align = false;
605    let mut zero_pad = false;
606
607    for ch in flags.chars() {
608        match ch {
609            '+' => sign_always = true,
610            '-' => left_align = true,
611            '0' => zero_pad = true,
612            _ => {
613                return Err(num2str_error_with_message(
614                    format!(
615                    "num2str: unsupported format flag '{}'; only '+', '-', and '0' are supported",
616                    ch
617                ),
618                    &NUM2STR_ERROR_INVALID_FORMAT,
619                ))
620            }
621        }
622    }
623
624    if let Some(p) = precision {
625        if p > MAX_PRECISION {
626            return Err(num2str_error_with_message(
627                format!("num2str: precision must satisfy 0 <= p <= {MAX_PRECISION}"),
628                &NUM2STR_ERROR_INVALID_PRECISION,
629            ));
630        }
631    }
632
633    let (kind, uppercase) = match conversion {
634        'f' => (CustomKind::Fixed, false),
635        'F' => (CustomKind::Fixed, true),
636        'e' => (CustomKind::Exponent, false),
637        'E' => (CustomKind::Exponent, true),
638        'g' => (CustomKind::General, false),
639        'G' => (CustomKind::General, true),
640        _ => unreachable!(),
641    };
642
643    Ok(CustomFormat {
644        kind,
645        width,
646        precision,
647        sign_always,
648        left_align,
649        zero_pad,
650        uppercase,
651    })
652}
653
654async fn extract_numeric_data(value: Value) -> BuiltinResult<NumericData> {
655    match value {
656        Value::Num(n) => Ok(NumericData::Real {
657            data: vec![n],
658            rows: 1,
659            cols: 1,
660        }),
661        Value::Int(i) => Ok(NumericData::Integer {
662            storage: integer_storage_from_scalar(i),
663            rows: 1,
664            cols: 1,
665        }),
666        Value::Bool(b) => Ok(NumericData::Real {
667            data: vec![if b { 1.0 } else { 0.0 }],
668            rows: 1,
669            cols: 1,
670        }),
671        Value::Tensor(t) => tensor_to_numeric_data(t),
672        Value::LogicalArray(la) => {
673            let tensor = tensor::logical_to_tensor(&la)
674                .map_err(|_| num2str_error(&NUM2STR_ERROR_INVALID_INPUT))?;
675            tensor_to_numeric_data(tensor)
676        }
677        Value::Complex(re, im) => Ok(NumericData::Complex {
678            data: vec![(re, im)],
679            rows: 1,
680            cols: 1,
681        }),
682        Value::ComplexTensor(t) => complex_tensor_to_data(t),
683        Value::GpuTensor(handle) => {
684            let gathered = gpu_helpers::gather_tensor_async(&handle)
685                .await
686                .map_err(remap_num2str_flow)?;
687            tensor_to_numeric_data(gathered)
688        }
689        other => Err(num2str_error_with_message(
690            format!(
691                "{} {:?}; expected numeric or logical values",
692                NUM2STR_ERROR_INVALID_INPUT.message, other
693            ),
694            &NUM2STR_ERROR_INVALID_INPUT,
695        )),
696    }
697}
698
699fn tensor_to_numeric_data(tensor: Tensor) -> BuiltinResult<NumericData> {
700    if tensor.shape.len() > 2 {
701        return Err(num2str_error_with_message(
702            "num2str: input must be scalar, vector, or 2-D matrix",
703            &NUM2STR_ERROR_INVALID_INPUT,
704        ));
705    }
706    let rows = tensor.rows();
707    let cols = tensor.cols();
708    let storage = tensor
709        .into_numeric_storage()
710        .map_err(|_| num2str_error(&NUM2STR_ERROR_INVALID_INPUT))?;
711    match storage.into_integer_storage() {
712        Ok(storage) => Ok(NumericData::Integer {
713            storage: storage.clone(),
714            rows,
715            cols,
716        }),
717        Err(storage) => Ok(NumericData::Real {
718            data: storage.materialize_f64(),
719            rows,
720            cols,
721        }),
722    }
723}
724
725fn complex_tensor_to_data(tensor: ComplexTensor) -> BuiltinResult<NumericData> {
726    if tensor.shape.len() > 2 {
727        return Err(num2str_error_with_message(
728            "num2str: complex input must be scalar, vector, or 2-D matrix",
729            &NUM2STR_ERROR_INVALID_INPUT,
730        ));
731    }
732    let rows = tensor.rows;
733    let cols = tensor.cols;
734    if let Some(storage) = tensor.integer_storage() {
735        return Ok(NumericData::IntegerComplex {
736            storage: storage.clone(),
737            rows,
738            cols,
739        });
740    }
741    Ok(NumericData::Complex {
742        data: tensor.materialize_f64(),
743        rows,
744        cols,
745    })
746}
747
748#[derive(Clone)]
749struct CellEntry {
750    text: String,
751    width: usize,
752}
753
754fn format_numeric_data(data: NumericData, options: &FormatOptions) -> BuiltinResult<CharArray> {
755    match data {
756        NumericData::Real { data, rows, cols } => format_real_matrix(&data, rows, cols, options),
757        NumericData::Complex { data, rows, cols } => {
758            format_complex_matrix(&data, rows, cols, options)
759        }
760        NumericData::IntegerComplex {
761            storage,
762            rows,
763            cols,
764        } => format_integer_complex_matrix(&storage, rows, cols, options),
765        NumericData::Integer {
766            storage,
767            rows,
768            cols,
769        } => format_integer_matrix(&storage, rows, cols, options),
770    }
771}
772
773fn integer_storage_from_scalar(value: IntValue) -> IntegerStorage {
774    match value {
775        IntValue::I8(value) => IntegerStorage::I8(vec![value]),
776        IntValue::I16(value) => IntegerStorage::I16(vec![value]),
777        IntValue::I32(value) => IntegerStorage::I32(vec![value]),
778        IntValue::I64(value) => IntegerStorage::I64(vec![value]),
779        IntValue::U8(value) => IntegerStorage::U8(vec![value]),
780        IntValue::U16(value) => IntegerStorage::U16(vec![value]),
781        IntValue::U32(value) => IntegerStorage::U32(vec![value]),
782        IntValue::U64(value) => IntegerStorage::U64(vec![value]),
783    }
784}
785
786fn format_real_matrix(
787    data: &[f64],
788    rows: usize,
789    cols: usize,
790    options: &FormatOptions,
791) -> BuiltinResult<CharArray> {
792    if rows == 0 {
793        return CharArray::new(Vec::new(), 0, 0)
794            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
795    }
796    if cols == 0 {
797        return CharArray::new(Vec::new(), rows, 0)
798            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
799    }
800
801    let mut entries = vec![
802        vec![
803            CellEntry {
804                text: String::new(),
805                width: 0
806            };
807            cols
808        ];
809        rows
810    ];
811    let mut col_widths = vec![0usize; cols];
812
813    for (col, width) in col_widths.iter_mut().enumerate() {
814        for (row, row_entries) in entries.iter_mut().enumerate() {
815            let idx = row + col * rows;
816            let value = data.get(idx).copied().unwrap_or(0.0);
817            let text = format_real(value, &options.spec, options.decimal);
818            let entry_width = text.chars().count();
819            row_entries[col] = CellEntry {
820                text,
821                width: entry_width,
822            };
823            if entry_width > *width {
824                *width = entry_width;
825            }
826        }
827    }
828
829    if cols > 1 {
830        for (idx, width) in col_widths.iter_mut().enumerate() {
831            if idx > 0 {
832                *width += 1;
833            }
834        }
835    }
836
837    let rows_str = assemble_rows(entries, col_widths);
838    rows_to_char_array(rows_str)
839}
840
841fn format_integer_matrix(
842    storage: &IntegerStorage,
843    rows: usize,
844    cols: usize,
845    options: &FormatOptions,
846) -> BuiltinResult<CharArray> {
847    if rows == 0 {
848        return CharArray::new(Vec::new(), 0, 0)
849            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
850    }
851    if cols == 0 {
852        return CharArray::new(Vec::new(), rows, 0)
853            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
854    }
855
856    let mut entries = vec![
857        vec![
858            CellEntry {
859                text: String::new(),
860                width: 0,
861            };
862            cols
863        ];
864        rows
865    ];
866    let mut col_widths = vec![0usize; cols];
867
868    for (col, width) in col_widths.iter_mut().enumerate() {
869        for (row, row_entries) in entries.iter_mut().enumerate() {
870            let text = format_integer(
871                integer_storage_decimal(storage, row + col * rows),
872                &options.spec,
873                options.decimal,
874            );
875            let entry_width = text.chars().count();
876            row_entries[col] = CellEntry {
877                text,
878                width: entry_width,
879            };
880            *width = (*width).max(entry_width);
881        }
882    }
883
884    if cols > 1 {
885        for (idx, width) in col_widths.iter_mut().enumerate() {
886            if idx > 0 {
887                *width += 1;
888            }
889        }
890    }
891
892    rows_to_char_array(assemble_rows(entries, col_widths))
893}
894
895fn integer_storage_decimal(storage: &IntegerStorage, index: usize) -> String {
896    match storage {
897        IntegerStorage::I8(values) => values[index].to_string(),
898        IntegerStorage::I16(values) => values[index].to_string(),
899        IntegerStorage::I32(values) => values[index].to_string(),
900        IntegerStorage::I64(values) => values[index].to_string(),
901        IntegerStorage::U8(values) => values[index].to_string(),
902        IntegerStorage::U16(values) => values[index].to_string(),
903        IntegerStorage::U32(values) => values[index].to_string(),
904        IntegerStorage::U64(values) => values[index].to_string(),
905    }
906}
907
908fn format_integer_complex_matrix(
909    storage: &IntegerComplexStorage,
910    rows: usize,
911    cols: usize,
912    options: &FormatOptions,
913) -> BuiltinResult<CharArray> {
914    if rows == 0 {
915        return CharArray::new(Vec::new(), 0, 0)
916            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
917    }
918    if cols == 0 {
919        return CharArray::new(Vec::new(), rows, 0)
920            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
921    }
922
923    let mut entries = vec![
924        vec![
925            CellEntry {
926                text: String::new(),
927                width: 0
928            };
929            cols
930        ];
931        rows
932    ];
933    let mut col_widths = vec![0usize; cols];
934
935    for (col, width) in col_widths.iter_mut().enumerate() {
936        for (row, row_entries) in entries.iter_mut().enumerate() {
937            let text = format_integer_complex(storage, row + col * rows, options);
938            let entry_width = text.chars().count();
939            row_entries[col] = CellEntry {
940                text,
941                width: entry_width,
942            };
943            *width = (*width).max(entry_width);
944        }
945    }
946
947    if cols > 1 {
948        for (idx, width) in col_widths.iter_mut().enumerate() {
949            if idx > 0 {
950                *width += 1;
951            }
952        }
953    }
954
955    rows_to_char_array(assemble_rows(entries, col_widths))
956}
957
958fn format_integer_complex(
959    storage: &IntegerComplexStorage,
960    index: usize,
961    options: &FormatOptions,
962) -> String {
963    let real_raw = integer_storage_decimal(&storage.real, index);
964    let imag_raw = integer_storage_decimal(&storage.imag, index);
965    let real_is_zero = real_raw == "0";
966    let real_str = format_integer(real_raw, &options.spec, options.decimal);
967    let Some(imag_abs_raw) = integer_abs_decimal(&imag_raw) else {
968        return real_str;
969    };
970    let imag_str = format_integer(imag_abs_raw, &options.spec, options.decimal);
971    let imag_sign = if imag_raw.starts_with('-') { '-' } else { '+' };
972
973    if real_is_zero {
974        if imag_sign == '-' {
975            return format!("-{imag_str}i");
976        }
977        return format!("{imag_str}i");
978    }
979
980    format!("{real_str} {imag_sign} {imag_str}i")
981}
982
983fn integer_abs_decimal(value: &str) -> Option<String> {
984    if value == "0" {
985        None
986    } else {
987        Some(value.strip_prefix('-').unwrap_or(value).to_string())
988    }
989}
990
991fn format_complex_matrix(
992    data: &[(f64, f64)],
993    rows: usize,
994    cols: usize,
995    options: &FormatOptions,
996) -> BuiltinResult<CharArray> {
997    if rows == 0 {
998        return CharArray::new(Vec::new(), 0, 0)
999            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
1000    }
1001    if cols == 0 {
1002        return CharArray::new(Vec::new(), rows, 0)
1003            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
1004    }
1005
1006    let mut entries = vec![
1007        vec![
1008            CellEntry {
1009                text: String::new(),
1010                width: 0
1011            };
1012            cols
1013        ];
1014        rows
1015    ];
1016    let mut col_widths = vec![0usize; cols];
1017
1018    for (col, width) in col_widths.iter_mut().enumerate() {
1019        for (row, row_entries) in entries.iter_mut().enumerate() {
1020            let idx = row + col * rows;
1021            let (re, im) = data.get(idx).copied().unwrap_or((0.0, 0.0));
1022            let text = format_complex(re, im, &options.spec, options.decimal);
1023            let entry_width = text.chars().count();
1024            row_entries[col] = CellEntry {
1025                text,
1026                width: entry_width,
1027            };
1028            if entry_width > *width {
1029                *width = entry_width;
1030            }
1031        }
1032    }
1033
1034    if cols > 1 {
1035        for (idx, width) in col_widths.iter_mut().enumerate() {
1036            if idx > 0 {
1037                *width += 1;
1038            }
1039        }
1040    }
1041
1042    let rows_str = assemble_rows(entries, col_widths);
1043    rows_to_char_array(rows_str)
1044}
1045
1046fn assemble_rows(entries: Vec<Vec<CellEntry>>, col_widths: Vec<usize>) -> Vec<String> {
1047    entries
1048        .into_iter()
1049        .map(|row_entries| {
1050            row_entries
1051                .into_iter()
1052                .enumerate()
1053                .fold(String::new(), |mut acc, (col, entry)| {
1054                    if col > 0 {
1055                        acc.push(' ');
1056                    }
1057                    let target = col_widths[col];
1058                    let pad = target.saturating_sub(entry.width);
1059                    acc.extend(std::iter::repeat_n(' ', pad));
1060                    acc.push_str(&entry.text);
1061                    acc
1062                })
1063        })
1064        .collect()
1065}
1066
1067fn rows_to_char_array(rows: Vec<String>) -> BuiltinResult<CharArray> {
1068    if rows.is_empty() {
1069        return CharArray::new(Vec::new(), 0, 0)
1070            .map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL));
1071    }
1072    let row_count = rows.len();
1073    let col_count = rows
1074        .iter()
1075        .map(|row| row.chars().count())
1076        .max()
1077        .unwrap_or(0);
1078
1079    let mut data = Vec::with_capacity(row_count * col_count);
1080    for row in rows {
1081        let mut chars: Vec<char> = row.chars().collect();
1082        if chars.len() < col_count {
1083            chars.extend(std::iter::repeat_n(' ', col_count - chars.len()));
1084        }
1085        data.extend(chars);
1086    }
1087
1088    CharArray::new(data, row_count, col_count).map_err(|_| num2str_error(&NUM2STR_ERROR_INTERNAL))
1089}
1090
1091fn format_real(value: f64, spec: &FormatSpec, decimal: char) -> String {
1092    let text = match spec {
1093        FormatSpec::General { digits } => format_general(value, *digits, false),
1094        FormatSpec::Custom(custom) => format_custom(value, custom),
1095    };
1096    apply_decimal_locale(text, decimal)
1097}
1098
1099fn format_integer(value: String, spec: &FormatSpec, decimal: char) -> String {
1100    let (negative, digits) = value
1101        .strip_prefix('-')
1102        .map_or((false, value.as_str()), |digits| (true, digits));
1103    let text = match spec {
1104        FormatSpec::General { digits: precision } => {
1105            format_integer_general(negative, digits, *precision, false)
1106        }
1107        FormatSpec::Custom(custom) => {
1108            let precision = custom.precision.unwrap_or(match custom.kind {
1109                CustomKind::Fixed | CustomKind::Exponent => 6,
1110                CustomKind::General => DEFAULT_PRECISION,
1111            });
1112            let text = match custom.kind {
1113                CustomKind::Fixed => format_integer_fixed(negative, digits, precision),
1114                CustomKind::Exponent => {
1115                    format_integer_exponent(negative, digits, precision + 1, custom.uppercase)
1116                }
1117                CustomKind::General => {
1118                    format_integer_general(negative, digits, precision.max(1), custom.uppercase)
1119                }
1120            };
1121            apply_format_flags(text, custom)
1122        }
1123    };
1124    apply_decimal_locale(text, decimal)
1125}
1126
1127fn format_integer_fixed(negative: bool, digits: &str, precision: usize) -> String {
1128    let mut text = String::new();
1129    if negative {
1130        text.push('-');
1131    }
1132    text.push_str(digits);
1133    if precision > 0 {
1134        text.push('.');
1135        text.extend(std::iter::repeat_n('0', precision));
1136    }
1137    text
1138}
1139
1140fn format_integer_general(
1141    negative: bool,
1142    digits: &str,
1143    precision: usize,
1144    uppercase: bool,
1145) -> String {
1146    let significant = precision.max(1);
1147    if digits.len() <= significant {
1148        let mut text = String::new();
1149        if negative {
1150            text.push('-');
1151        }
1152        text.push_str(digits);
1153        return text;
1154    }
1155    format_integer_exponent(negative, digits, significant, uppercase)
1156}
1157
1158fn format_integer_exponent(
1159    negative: bool,
1160    digits: &str,
1161    significant: usize,
1162    uppercase: bool,
1163) -> String {
1164    let (rounded, exponent) = round_integer_significant(digits, significant.max(1));
1165    let mut text = String::new();
1166    if negative {
1167        text.push('-');
1168    }
1169    let first = rounded.as_bytes()[0] as char;
1170    text.push(first);
1171    let fraction = rounded.get(1..).unwrap_or("").trim_end_matches('0');
1172    if !fraction.is_empty() {
1173        text.push('.');
1174        text.push_str(fraction);
1175    }
1176    text.push(if uppercase { 'E' } else { 'e' });
1177    text.push('+');
1178    text.push_str(&exponent.to_string());
1179    text
1180}
1181
1182fn round_integer_significant(digits: &str, significant: usize) -> (String, usize) {
1183    let exponent = digits.len() - 1;
1184    if digits.len() <= significant {
1185        return (digits.to_string(), exponent);
1186    }
1187
1188    let mut rounded: Vec<u8> = digits.as_bytes()[..significant].to_vec();
1189    if digits.as_bytes()[significant] >= b'5' {
1190        for position in (0..rounded.len()).rev() {
1191            if rounded[position] < b'9' {
1192                rounded[position] += 1;
1193                break;
1194            }
1195            rounded[position] = b'0';
1196            if position == 0 {
1197                let mut carry = String::from("1");
1198                carry.extend(std::iter::repeat_n('0', significant.saturating_sub(1)));
1199                return (carry, exponent + 1);
1200            }
1201        }
1202    }
1203    (
1204        String::from_utf8(rounded).expect("integer digits are ascii"),
1205        exponent,
1206    )
1207}
1208
1209fn format_complex(re: f64, im: f64, spec: &FormatSpec, decimal: char) -> String {
1210    let real_str = format_real(re, spec, decimal);
1211    let imag_sign = if im.is_sign_negative() { '-' } else { '+' };
1212    let abs_im = if im == 0.0 { 0.0 } else { im.abs() };
1213    let imag_str = format_real(abs_im, spec, decimal);
1214
1215    if abs_im == 0.0 && !im.is_nan() {
1216        return real_str;
1217    }
1218
1219    if re == 0.0 && !re.is_sign_negative() && !re.is_nan() {
1220        if im.is_sign_negative() && !im.is_nan() {
1221            return format!(
1222                "{}i",
1223                if imag_str.starts_with('-') {
1224                    imag_str.clone()
1225                } else {
1226                    format!("-{imag_str}")
1227                }
1228            );
1229        }
1230        return format!("{imag_str}i");
1231    }
1232
1233    format!("{real_str} {imag_sign} {imag_str}i")
1234}
1235
1236fn format_general(value: f64, digits: usize, uppercase: bool) -> String {
1237    if value.is_nan() {
1238        return "NaN".to_string();
1239    }
1240    if value.is_infinite() {
1241        return if value.is_sign_negative() {
1242            "-Inf".to_string()
1243        } else {
1244            "Inf".to_string()
1245        };
1246    }
1247    if value == 0.0 {
1248        return "0".to_string();
1249    }
1250
1251    let sig_digits = digits.max(1);
1252    let abs_val = value.abs();
1253    let exp10 = abs_val.log10().floor() as i32;
1254    let use_scientific = exp10 < -4 || exp10 >= sig_digits as i32;
1255
1256    if use_scientific {
1257        let precision = sig_digits.saturating_sub(1);
1258        let s = if uppercase {
1259            format!("{:.*E}", precision, value)
1260        } else {
1261            format!("{:.*e}", precision, value)
1262        };
1263        let marker = if uppercase { 'E' } else { 'e' };
1264        if let Some(idx) = s.find(marker) {
1265            let (mantissa, exponent) = s.split_at(idx);
1266            let mut mant = mantissa.to_string();
1267            trim_trailing_zeros(&mut mant);
1268            normalize_negative_zero(&mut mant);
1269            let mut result = mant;
1270            result.push_str(exponent);
1271            return result;
1272        }
1273        s
1274    } else {
1275        let decimals = if sig_digits as i32 - 1 - exp10 < 0 {
1276            0
1277        } else {
1278            (sig_digits as i32 - 1 - exp10) as usize
1279        };
1280        let mut s = format!("{:.*}", decimals, value);
1281        trim_trailing_zeros(&mut s);
1282        normalize_negative_zero(&mut s);
1283        s
1284    }
1285}
1286
1287fn trim_trailing_zeros(text: &mut String) {
1288    if let Some(dot_pos) = text.find('.') {
1289        let mut end = text.len();
1290        while end > dot_pos + 1 && text.as_bytes()[end - 1] == b'0' {
1291            end -= 1;
1292        }
1293        if end > dot_pos && text.as_bytes()[end - 1] == b'.' {
1294            end -= 1;
1295        }
1296        text.truncate(end);
1297    }
1298}
1299
1300fn normalize_negative_zero(text: &mut String) {
1301    if text.starts_with('-') && text.chars().skip(1).all(|ch| ch == '0') {
1302        *text = "0".to_string();
1303    }
1304}
1305
1306fn format_custom(value: f64, fmt: &CustomFormat) -> String {
1307    if value.is_nan() {
1308        return "NaN".to_string();
1309    }
1310    if value.is_infinite() {
1311        return if value.is_sign_negative() {
1312            "-Inf".to_string()
1313        } else {
1314            "Inf".to_string()
1315        };
1316    }
1317
1318    let precision = fmt.precision.unwrap_or(match fmt.kind {
1319        CustomKind::Fixed | CustomKind::Exponent => 6,
1320        CustomKind::General => DEFAULT_PRECISION,
1321    });
1322
1323    let mut text = match fmt.kind {
1324        CustomKind::Fixed => format!("{:.*}", precision, value),
1325        CustomKind::Exponent => {
1326            let mut s = format!("{:.*e}", precision, value);
1327            if fmt.uppercase {
1328                s = s.to_uppercase();
1329            }
1330            s
1331        }
1332        CustomKind::General => format_general(value, precision.max(1), fmt.uppercase),
1333    };
1334
1335    if fmt.kind != CustomKind::Fixed {
1336        trim_trailing_zeros(&mut text);
1337        normalize_negative_zero(&mut text);
1338    }
1339
1340    apply_format_flags(text, fmt)
1341}
1342
1343fn apply_decimal_locale(text: String, decimal: char) -> String {
1344    if decimal == '.' {
1345        return text;
1346    }
1347    let mut replaced = false;
1348    text.chars()
1349        .map(|ch| {
1350            if ch == '.' && !replaced {
1351                replaced = true;
1352                decimal
1353            } else {
1354                ch
1355            }
1356        })
1357        .collect()
1358}
1359
1360fn apply_format_flags(mut text: String, fmt: &CustomFormat) -> String {
1361    if fmt.sign_always && !text.starts_with('-') && !text.starts_with('+') && text != "NaN" {
1362        text.insert(0, '+');
1363    }
1364
1365    let width = fmt.width.unwrap_or(0);
1366    if width == 0 {
1367        return text;
1368    }
1369
1370    let len = text.chars().count();
1371    if len >= width {
1372        return text;
1373    }
1374
1375    let pad_count = width - len;
1376    let pad_char = if fmt.zero_pad && !fmt.left_align {
1377        '0'
1378    } else {
1379        ' '
1380    };
1381
1382    if fmt.left_align {
1383        let mut result = text.clone();
1384        result.extend(std::iter::repeat_n(' ', pad_count));
1385        return result;
1386    }
1387
1388    if pad_char == '0' && (text.starts_with('+') || text.starts_with('-')) {
1389        let mut chars = text.chars();
1390        let sign = chars.next().unwrap();
1391        let remainder: String = chars.collect();
1392        let mut result = String::with_capacity(width);
1393        result.push(sign);
1394        result.extend(std::iter::repeat_n('0', pad_count));
1395        result.push_str(&remainder);
1396        return result;
1397    }
1398
1399    let mut result = String::with_capacity(width);
1400    result.extend(std::iter::repeat_n(' ', pad_count));
1401    result.push_str(&text);
1402    result
1403}
1404
1405#[cfg(test)]
1406pub(crate) mod tests {
1407    use super::*;
1408    use crate::builtins::common::test_support;
1409    use runmat_builtins::{ResolveContext, Type};
1410
1411    fn num2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1412        futures::executor::block_on(super::num2str_builtin(value, rest))
1413    }
1414    use runmat_value::{IntValue, IntegerComplexStorage, IntegerStorage, LogicalArray, Tensor};
1415
1416    fn error_message(err: crate::RuntimeError) -> String {
1417        err.message().to_string()
1418    }
1419
1420    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1421    #[test]
1422    fn num2str_scalar_default_precision() {
1423        let value = Value::Num(std::f64::consts::PI);
1424        let out = num2str_builtin(value, Vec::new()).expect("num2str");
1425        match out {
1426            Value::CharArray(ca) => {
1427                let text: String = ca.data.iter().collect();
1428                assert_eq!(ca.rows, 1);
1429                assert!(text.starts_with("3.1415926535897"));
1430            }
1431            other => panic!("expected char array, got {other:?}"),
1432        }
1433    }
1434
1435    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1436    #[test]
1437    fn num2str_precision_argument() {
1438        let value = Value::Num(std::f64::consts::PI);
1439        let out = num2str_builtin(value, vec![Value::Int(IntValue::I32(4))]).expect("num2str");
1440        match out {
1441            Value::CharArray(ca) => {
1442                let text: String = ca.data.iter().collect();
1443                assert_eq!(text.trim(), "3.142");
1444            }
1445            other => panic!("expected char array, got {other:?}"),
1446        }
1447    }
1448
1449    #[test]
1450    fn num2str_precision_parser_preserves_typed_integer_tensor_bounds() {
1451        let precision =
1452            Tensor::new_integer(IntegerStorage::U64(vec![52]), vec![1, 1]).expect("precision");
1453        assert_eq!(
1454            try_extract_precision(&Value::Tensor(precision)).unwrap(),
1455            Some(52)
1456        );
1457
1458        let too_large =
1459            Tensor::new_integer(IntegerStorage::U64(vec![53]), vec![1, 1]).expect("precision");
1460        assert!(try_extract_precision(&Value::Tensor(too_large)).is_err());
1461
1462        let negative =
1463            Tensor::new_integer(IntegerStorage::I16(vec![-1]), vec![1, 1]).expect("precision");
1464        assert!(try_extract_precision(&Value::Tensor(negative)).is_err());
1465
1466        assert!(try_extract_precision(&Value::Int(IntValue::U64(u64::MAX))).is_err());
1467    }
1468
1469    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1470    #[test]
1471    fn num2str_matrix_alignment() {
1472        let tensor =
1473            Tensor::new(vec![1.0, 78.0, 23.0, 9.0, 456.0, 10.0], vec![2, 3]).expect("tensor");
1474        let out = num2str_builtin(Value::Tensor(tensor), Vec::new()).expect("num2str");
1475        match out {
1476            Value::CharArray(ca) => {
1477                assert_eq!(ca.rows, 2);
1478                assert_eq!(ca.cols, 11);
1479                let rows: Vec<String> = ca
1480                    .data
1481                    .chunks(ca.cols)
1482                    .map(|chunk| chunk.iter().collect())
1483                    .collect();
1484                assert_eq!(rows[0], " 1  23  456");
1485                assert_eq!(rows[1], "78   9   10");
1486            }
1487            other => panic!("expected char array, got {other:?}"),
1488        }
1489    }
1490
1491    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1492    #[test]
1493    fn num2str_custom_format() {
1494        let tensor = Tensor::new(vec![1.234, 5.678], vec![1, 2]).expect("tensor");
1495        let fmt = Value::String("%.2f".to_string());
1496        let out = num2str_builtin(Value::Tensor(tensor), vec![fmt]).expect("num2str");
1497        match out {
1498            Value::CharArray(ca) => {
1499                let text: String = ca.data.iter().collect();
1500                assert_eq!(text, "1.23  5.68");
1501            }
1502            other => panic!("expected char array, got {other:?}"),
1503        }
1504    }
1505
1506    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1507    #[test]
1508    fn num2str_preserves_exact_uint64_across_format_modes() {
1509        let scalar = num2str_builtin(Value::Int(IntValue::U64(u64::MAX)), Vec::new())
1510            .expect("default integer format");
1511        match scalar {
1512            Value::CharArray(ca) => {
1513                let text: String = ca.data.iter().collect();
1514                assert_eq!(text, "1.84467440737096e+19");
1515            }
1516            other => panic!("expected char array, got {other:?}"),
1517        }
1518
1519        let fixed = num2str_builtin(
1520            Value::Int(IntValue::U64(u64::MAX)),
1521            vec![Value::String("%.2f".into())],
1522        )
1523        .expect("fixed integer format");
1524        match fixed {
1525            Value::CharArray(ca) => {
1526                let text: String = ca.data.iter().collect();
1527                assert_eq!(text, format!("{}.00", u64::MAX));
1528            }
1529            other => panic!("expected char array, got {other:?}"),
1530        }
1531
1532        let rounded = num2str_builtin(
1533            Value::Int(IntValue::U64(u64::MAX)),
1534            vec![Value::Int(IntValue::I32(4))],
1535        )
1536        .expect("precision integer format");
1537        match rounded {
1538            Value::CharArray(ca) => {
1539                let text: String = ca.data.iter().collect();
1540                assert_eq!(text, "1.845e+19");
1541            }
1542            other => panic!("expected char array, got {other:?}"),
1543        }
1544    }
1545
1546    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1547    #[test]
1548    fn num2str_complex_values() {
1549        let complex = ComplexTensor::new(vec![(3.0, 4.0), (5.0, -6.0)], vec![1, 2]).expect("cplx");
1550        let out = num2str_builtin(Value::ComplexTensor(complex), Vec::new()).expect("num2str");
1551        match out {
1552            Value::CharArray(ca) => {
1553                let text: String = ca.data.iter().collect();
1554                assert_eq!(text, "3 + 4i  5 - 6i");
1555            }
1556            other => panic!("expected char array, got {other:?}"),
1557        }
1558    }
1559
1560    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1561    #[test]
1562    fn num2str_complex_integer_tensor_reads_exact_storage_without_mirror() {
1563        let storage = IntegerComplexStorage::new(
1564            IntegerStorage::U64(vec![u64::MAX, 9_007_199_254_740_993]),
1565            IntegerStorage::U64(vec![7, 0]),
1566        )
1567        .expect("complex integer storage");
1568        let tensor =
1569            ComplexTensor::new_integer(storage, vec![1, 2]).expect("complex integer tensor");
1570
1571        let out = num2str_builtin(Value::ComplexTensor(tensor), Vec::new()).expect("num2str");
1572        match out {
1573            Value::CharArray(ca) => {
1574                let text: String = ca.data.iter().collect();
1575                assert_eq!(text, "1.84467440737096e+19 + 7i  9.00719925474099e+15");
1576            }
1577            other => panic!("expected char array, got {other:?}"),
1578        }
1579    }
1580
1581    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1582    #[test]
1583    fn num2str_complex_integer_tensor_fixed_format_keeps_exact_digits() {
1584        let storage = IntegerComplexStorage::new(
1585            IntegerStorage::U64(vec![u64::MAX, 9_007_199_254_740_993]),
1586            IntegerStorage::U64(vec![7, 0]),
1587        )
1588        .expect("complex integer storage");
1589        let tensor =
1590            ComplexTensor::new_integer(storage, vec![1, 2]).expect("complex integer tensor");
1591
1592        let out = num2str_builtin(
1593            Value::ComplexTensor(tensor),
1594            vec![Value::String("%.0f".to_string())],
1595        )
1596        .expect("num2str");
1597        match out {
1598            Value::CharArray(ca) => {
1599                let text: String = ca.data.iter().collect();
1600                assert_eq!(text, format!("{} + 7i  9007199254740993", u64::MAX));
1601            }
1602            other => panic!("expected char array, got {other:?}"),
1603        }
1604    }
1605
1606    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1607    #[test]
1608    fn num2str_local_decimal() {
1609        std::env::set_var("RUNMAT_DECIMAL_SEPARATOR", ",");
1610        let out =
1611            num2str_builtin(Value::Num(0.5), vec![Value::String("local".into())]).expect("num2str");
1612        std::env::remove_var("RUNMAT_DECIMAL_SEPARATOR");
1613        match out {
1614            Value::CharArray(ca) => {
1615                let text: String = ca.data.iter().collect();
1616                assert_eq!(text, "0,5");
1617            }
1618            other => panic!("expected char array, got {other:?}"),
1619        }
1620    }
1621
1622    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1623    #[test]
1624    fn num2str_logical_array() {
1625        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).expect("logical");
1626        let out = num2str_builtin(Value::LogicalArray(logical), Vec::new()).expect("num2str");
1627        match out {
1628            Value::CharArray(ca) => {
1629                let text: String = ca.data.iter().collect();
1630                assert_eq!(text, "1  0  1");
1631            }
1632            other => panic!("expected char array, got {other:?}"),
1633        }
1634    }
1635
1636    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1637    #[test]
1638    fn num2str_gpu_tensor_roundtrip() {
1639        test_support::with_test_provider(|provider| {
1640            let tensor = Tensor::new(vec![10.5, 20.5], vec![1, 2]).expect("tensor");
1641            let view = runmat_accelerate_api::HostTensorView {
1642                data: &tensor.materialize_f64(),
1643                shape: &tensor.shape,
1644            };
1645            let handle = provider.upload(&view).expect("upload");
1646            let out = num2str_builtin(Value::GpuTensor(handle), vec![Value::String("%.1f".into())])
1647                .expect("num2str");
1648            match out {
1649                Value::CharArray(ca) => {
1650                    let text: String = ca.data.iter().collect();
1651                    assert_eq!(text, "10.5  20.5");
1652                }
1653                other => panic!("expected char array, got {other:?}"),
1654            }
1655        });
1656    }
1657
1658    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1659    #[test]
1660    fn num2str_invalid_input_type() {
1661        let err =
1662            error_message(num2str_builtin(Value::String("hello".into()), Vec::new()).unwrap_err());
1663        assert!(err.contains("unsupported input type"));
1664    }
1665
1666    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1667    #[test]
1668    fn num2str_invalid_format_string() {
1669        let err = error_message(
1670            num2str_builtin(Value::Num(1.0), vec![Value::String("%q".into())]).unwrap_err(),
1671        );
1672        assert!(err.contains("unsupported format string"));
1673    }
1674
1675    #[test]
1676    fn num2str_type_is_string_scalar() {
1677        assert_eq!(
1678            string_scalar_type(&[Type::Num], &ResolveContext::new(Vec::new())),
1679            Type::String
1680        );
1681    }
1682}