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