1use 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}