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