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