Skip to main content

runmat_runtime/builtins/common/
format.rs

1//! Shared formatting helpers for string-producing builtins.
2
3use std::char;
4use std::iter::Peekable;
5use std::str::Chars;
6
7use runmat_builtins::{IntValue, LogicalArray, StringArray, Value};
8
9use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
10
11/// Stateful cursor over formatting arguments.
12#[derive(Debug)]
13pub struct ArgCursor<'a> {
14    args: &'a [Value],
15    index: usize,
16}
17
18impl<'a> ArgCursor<'a> {
19    pub fn new(args: &'a [Value]) -> Self {
20        Self { args, index: 0 }
21    }
22
23    pub fn remaining(&self) -> usize {
24        self.args.len().saturating_sub(self.index)
25    }
26
27    pub fn index(&self) -> usize {
28        self.index
29    }
30
31    fn next(&mut self) -> BuiltinResult<Value> {
32        if self.index >= self.args.len() {
33            return Err(format_error(
34                "sprintf: not enough input arguments for format specifier",
35            ));
36        }
37        let value = self.args[self.index].clone();
38        self.index += 1;
39        Ok(value)
40    }
41}
42
43fn format_error(message: impl Into<String>) -> RuntimeError {
44    build_runtime_error(message).build()
45}
46
47fn map_control_flow_with_context(err: RuntimeError, context: &str) -> RuntimeError {
48    crate::builtins::common::map_control_flow_with_builtin(err, context)
49}
50
51/// Result of formatting a string with the current cursor state.
52#[derive(Debug, Default, Clone)]
53pub struct FormatStepResult {
54    pub output: String,
55    pub consumed: usize,
56}
57
58#[derive(Clone, Copy, Default)]
59struct FormatFlags {
60    alternate: bool,
61    zero_pad: bool,
62    left_align: bool,
63    sign_plus: bool,
64    sign_space: bool,
65    grouping: bool,
66}
67
68#[derive(Clone, Copy)]
69enum Count {
70    Value(isize),
71    FromArgument,
72}
73
74#[derive(Clone, Copy)]
75struct FormatSpec {
76    flags: FormatFlags,
77    width: Option<Count>,
78    precision: Option<Count>,
79    conversion: char,
80}
81
82/// Format a MATLAB-style format string with the provided arguments.
83///
84/// This function consumes arguments in column-major order, supports field widths,
85/// precision (including the `*` form), and honours the usual printf flags for the
86/// subset required by MATLAB builtins. It is intentionally strict: errors are
87/// reported when format specifiers cannot be satisfied by the provided arguments.
88pub fn format_variadic(fmt: &str, args: &[Value]) -> BuiltinResult<String> {
89    let mut cursor = ArgCursor::new(args);
90    let step = format_variadic_with_cursor(fmt, &mut cursor)?;
91    Ok(step.output)
92}
93
94/// Format a string using the supplied cursor, returning the formatted text along
95/// with the number of arguments consumed during this pass.
96pub fn format_variadic_with_cursor(
97    fmt: &str,
98    cursor: &mut ArgCursor<'_>,
99) -> BuiltinResult<FormatStepResult> {
100    format_once(fmt, cursor)
101}
102
103fn format_once(fmt: &str, cursor: &mut ArgCursor<'_>) -> BuiltinResult<FormatStepResult> {
104    let mut chars = fmt.chars().peekable();
105    let mut out = String::with_capacity(fmt.len());
106    let mut consumed = 0usize;
107
108    while let Some(ch) = chars.next() {
109        if ch != '%' {
110            out.push(ch);
111            continue;
112        }
113
114        if let Some('%') = chars.peek() {
115            chars.next();
116            out.push('%');
117            continue;
118        }
119
120        let spec = parse_format_spec(&mut chars)?;
121        let (formatted, used) = apply_format_spec(spec, cursor)?;
122        consumed += used;
123        out.push_str(&formatted);
124    }
125
126    Ok(FormatStepResult {
127        output: out,
128        consumed,
129    })
130}
131
132fn parse_format_spec(chars: &mut Peekable<Chars<'_>>) -> BuiltinResult<FormatSpec> {
133    let mut flags = FormatFlags::default();
134    loop {
135        match chars.peek().copied() {
136            Some('#') => {
137                flags.alternate = true;
138                chars.next();
139            }
140            Some('0') => {
141                flags.zero_pad = true;
142                chars.next();
143            }
144            Some('-') => {
145                flags.left_align = true;
146                chars.next();
147            }
148            Some(' ') => {
149                flags.sign_space = true;
150                chars.next();
151            }
152            Some('+') => {
153                flags.sign_plus = true;
154                chars.next();
155            }
156            Some('\'') => {
157                flags.grouping = true;
158                chars.next();
159            }
160            Some('I') => {
161                // Locale-specific alternative digits are not implemented yet.
162                // Consume the flag to keep format parsing compatible.
163                chars.next();
164            }
165            _ => break,
166        }
167    }
168
169    let width = if let Some('*') = chars.peek() {
170        chars.next();
171        Some(Count::FromArgument)
172    } else {
173        parse_number(chars).map(Count::Value)
174    };
175
176    let precision = if let Some('.') = chars.peek() {
177        chars.next();
178        if let Some('*') = chars.peek() {
179            chars.next();
180            Some(Count::FromArgument)
181        } else {
182            Some(Count::Value(parse_number(chars).unwrap_or(0)))
183        }
184    } else {
185        None
186    };
187
188    // Length modifiers are ignored, but we must consume them to remain compatible.
189    if let Some(&('h' | 'l' | 'L' | 'z' | 'j' | 't')) = chars.peek() {
190        let current = chars.next().unwrap();
191        if matches!(current, 'h' | 'l') && chars.peek() == Some(&current) {
192            chars.next();
193        }
194    }
195
196    let conversion = chars
197        .next()
198        .ok_or_else(|| format_error("sprintf: incomplete format specifier"))?;
199
200    Ok(FormatSpec {
201        flags,
202        width,
203        precision,
204        conversion,
205    })
206}
207
208fn parse_number(chars: &mut Peekable<Chars<'_>>) -> Option<isize> {
209    let mut value: i128 = 0;
210    let mut seen = false;
211    while let Some(&ch) = chars.peek() {
212        if !ch.is_ascii_digit() {
213            break;
214        }
215        seen = true;
216        value = value * 10 + i128::from((ch as u8 - b'0') as i16);
217        chars.next();
218    }
219    if seen {
220        let capped = value
221            .clamp(isize::MIN as i128, isize::MAX as i128)
222            .try_into()
223            .unwrap_or(isize::MAX);
224        Some(capped)
225    } else {
226        None
227    }
228}
229
230fn apply_format_spec(
231    spec: FormatSpec,
232    cursor: &mut ArgCursor<'_>,
233) -> BuiltinResult<(String, usize)> {
234    let mut consumed = 0usize;
235    let mut flags = spec.flags;
236
237    let mut width = match spec.width {
238        Some(Count::Value(w)) => Some(w),
239        Some(Count::FromArgument) => {
240            let value = cursor.next()?;
241            consumed += 1;
242            let w = value_to_isize(&value)?;
243            Some(w)
244        }
245        None => None,
246    };
247
248    let precision = match spec.precision {
249        Some(Count::Value(p)) => Some(p),
250        Some(Count::FromArgument) => {
251            let value = cursor.next()?;
252            consumed += 1;
253            let p = value_to_isize(&value)?;
254            if p < 0 {
255                None
256            } else {
257                Some(p)
258            }
259        }
260        None => None,
261    };
262
263    if let Some(w) = width {
264        if w < 0 {
265            flags.left_align = true;
266            width = Some(-w);
267        }
268    }
269
270    let conversion = spec.conversion;
271    let formatted = match conversion {
272        'd' | 'i' => {
273            let value = cursor.next()?;
274            consumed += 1;
275            let int_value = value_to_i128(&value)?;
276            format_integer(
277                int_value,
278                int_value.is_negative(),
279                10,
280                flags,
281                width,
282                precision,
283                false,
284                false,
285            )
286        }
287        'u' => {
288            let value = cursor.next()?;
289            consumed += 1;
290            let uint_value = value_to_u128(&value)?;
291            format_unsigned(uint_value, 10, flags, width, precision, false, false)
292        }
293        'o' => {
294            let value = cursor.next()?;
295            consumed += 1;
296            let uint_value = value_to_u128(&value)?;
297            format_unsigned(
298                uint_value,
299                8,
300                flags,
301                width,
302                precision,
303                spec.flags.alternate,
304                false,
305            )
306        }
307        'x' => {
308            let value = cursor.next()?;
309            consumed += 1;
310            let uint_value = value_to_u128(&value)?;
311            format_unsigned(
312                uint_value,
313                16,
314                flags,
315                width,
316                precision,
317                spec.flags.alternate,
318                false,
319            )
320        }
321        'X' => {
322            let value = cursor.next()?;
323            consumed += 1;
324            let uint_value = value_to_u128(&value)?;
325            format_unsigned(
326                uint_value,
327                16,
328                flags,
329                width,
330                precision,
331                spec.flags.alternate,
332                true,
333            )
334        }
335        'b' => {
336            let value = cursor.next()?;
337            consumed += 1;
338            let uint_value = value_to_u128(&value)?;
339            format_unsigned(
340                uint_value,
341                2,
342                flags,
343                width,
344                precision,
345                spec.flags.alternate,
346                false,
347            )
348        }
349        'f' | 'F' | 'e' | 'E' | 'g' | 'G' => {
350            let value = cursor.next()?;
351            consumed += 1;
352            let float_value = value_to_f64(&value)?;
353            format_float(
354                float_value,
355                conversion,
356                flags,
357                width,
358                precision,
359                spec.flags.alternate,
360            )
361        }
362        's' => {
363            let value = cursor.next()?;
364            consumed += 1;
365            format_string(value, flags, width, precision)
366        }
367        'c' => {
368            let value = cursor.next()?;
369            consumed += 1;
370            format_char(value, flags, width)
371        }
372        other => {
373            return Err(format_error(format!(
374                "sprintf: unsupported format %{other}"
375            )));
376        }
377    }?;
378
379    Ok((formatted, consumed))
380}
381
382#[allow(clippy::too_many_arguments)]
383fn format_integer(
384    value: i128,
385    is_negative: bool,
386    base: u32,
387    mut flags: FormatFlags,
388    width: Option<isize>,
389    precision: Option<isize>,
390    alternate: bool,
391    uppercase: bool,
392) -> BuiltinResult<String> {
393    let mut sign = String::new();
394    let abs_val = value.unsigned_abs();
395
396    if is_negative {
397        sign.push('-');
398    } else if flags.sign_plus {
399        sign.push('+');
400    } else if flags.sign_space {
401        sign.push(' ');
402    }
403
404    if precision.is_some() {
405        flags.zero_pad = false;
406    }
407
408    let mut digits = to_base_string(abs_val, base, uppercase);
409    let precision_value = precision.unwrap_or(-1);
410    if precision_value == 0 && abs_val == 0 {
411        digits.clear();
412    }
413    if precision_value > 0 {
414        let required = precision_value as usize;
415        if digits.len() < required {
416            let mut buf = String::with_capacity(required);
417            for _ in 0..(required - digits.len()) {
418                buf.push('0');
419            }
420            buf.push_str(&digits);
421            digits = buf;
422        }
423    }
424
425    let mut prefix = String::new();
426    if alternate && abs_val != 0 {
427        match base {
428            8 => prefix.push('0'),
429            16 => {
430                prefix.push('0');
431                prefix.push(if uppercase { 'X' } else { 'x' });
432            }
433            2 => {
434                prefix.push('0');
435                prefix.push('b');
436            }
437            _ => {}
438        }
439    }
440
441    if flags.grouping && base == 10 {
442        digits = group_decimal_digits(&digits);
443    }
444
445    apply_width(sign, prefix, digits, flags, width, flags.zero_pad)
446}
447
448fn format_unsigned(
449    value: u128,
450    base: u32,
451    mut flags: FormatFlags,
452    width: Option<isize>,
453    precision: Option<isize>,
454    alternate: bool,
455    uppercase: bool,
456) -> BuiltinResult<String> {
457    if precision.is_some() {
458        flags.zero_pad = false;
459    }
460
461    let mut digits = to_base_string(value, base, uppercase);
462    let precision_value = precision.unwrap_or(-1);
463    if precision_value == 0 && value == 0 {
464        digits.clear();
465    }
466    if precision_value > 0 {
467        let required = precision_value as usize;
468        if digits.len() < required {
469            let mut buf = String::with_capacity(required);
470            for _ in 0..(required - digits.len()) {
471                buf.push('0');
472            }
473            buf.push_str(&digits);
474            digits = buf;
475        }
476    }
477
478    let mut prefix = String::new();
479    if alternate && value != 0 {
480        match base {
481            8 => prefix.push('0'),
482            16 => {
483                prefix.push_str(if uppercase { "0X" } else { "0x" });
484            }
485            2 => prefix.push_str("0b"),
486            _ => {}
487        }
488    }
489
490    if flags.grouping && base == 10 {
491        digits = group_decimal_digits(&digits);
492    }
493
494    apply_width(String::new(), prefix, digits, flags, width, flags.zero_pad)
495}
496
497fn format_float(
498    value: f64,
499    conversion: char,
500    flags: FormatFlags,
501    width: Option<isize>,
502    precision: Option<isize>,
503    alternate: bool,
504) -> BuiltinResult<String> {
505    let mut sign = String::new();
506    let mut magnitude = value;
507
508    if value.is_nan() {
509        return apply_width(
510            String::new(),
511            String::new(),
512            "NaN".to_string(),
513            flags,
514            width,
515            false,
516        );
517    }
518
519    if value.is_infinite() {
520        if value.is_sign_negative() {
521            sign.push('-');
522        } else if flags.sign_plus {
523            sign.push('+');
524        } else if flags.sign_space {
525            sign.push(' ');
526        }
527        let text = "Inf".to_string();
528        return apply_width(sign, String::new(), text, flags, width, false);
529    }
530
531    if value.is_sign_negative() || (value == 0.0 && (1.0 / value).is_sign_negative()) {
532        sign.push('-');
533        magnitude = -value;
534    } else if flags.sign_plus {
535        sign.push('+');
536    } else if flags.sign_space {
537        sign.push(' ');
538    }
539
540    let prec = precision.unwrap_or(6).max(0) as usize;
541    let mut body = match conversion {
542        'f' | 'F' => format!("{magnitude:.prec$}"),
543        'e' => format!("{magnitude:.prec$e}"),
544        'E' => format!("{magnitude:.prec$E}"),
545        'g' | 'G' => format_float_general(magnitude, prec, conversion.is_uppercase()),
546        _ => {
547            return Err(format_error(format!(
548                "sprintf: unsupported float conversion %{}",
549                conversion
550            )))
551        }
552    };
553
554    if alternate && !body.contains('.') && matches!(conversion, 'f' | 'F' | 'g' | 'G') {
555        body.push('.');
556    }
557
558    if flags.grouping && matches!(conversion, 'f' | 'F' | 'g' | 'G') {
559        body = group_float_mantissa(&body);
560    }
561
562    let zero_pad_allowed = flags.zero_pad && !flags.left_align;
563    apply_width(sign, String::new(), body, flags, width, zero_pad_allowed)
564}
565
566fn format_float_general(value: f64, precision: usize, uppercase: bool) -> String {
567    if value == 0.0 {
568        if precision == 0 {
569            return "0".to_string();
570        }
571        let mut zero = String::from("0");
572        if precision > 0 {
573            zero.push('.');
574            zero.push_str(&"0".repeat(precision.saturating_sub(1)));
575        }
576        return zero;
577    }
578
579    let mut prec = precision;
580    if prec == 0 {
581        prec = 1;
582    }
583
584    let abs_val = value.abs();
585    let exp = abs_val.log10().floor() as i32;
586    let use_exp = exp < -4 || exp >= prec as i32;
587
588    if use_exp {
589        let mut s = format!("{:.*e}", prec - 1, value);
590        if uppercase {
591            s = s.to_uppercase();
592        }
593        trim_trailing_zeros(&mut s, true);
594        s
595    } else {
596        let mut s = format!("{:.*}", prec.max(1) - 1, value);
597        trim_trailing_zeros(&mut s, false);
598        s
599    }
600}
601
602fn trim_trailing_zeros(text: &mut String, keep_exponent: bool) {
603    if let Some(dot_idx) = text.find('.') {
604        let mut end = text.len();
605        while end > dot_idx + 1 {
606            let byte = text.as_bytes()[end - 1];
607            if byte == b'0' {
608                end -= 1;
609            } else {
610                break;
611            }
612        }
613        if end > dot_idx + 1 && text.as_bytes()[end - 1] == b'.' {
614            end -= 1;
615        }
616        if keep_exponent {
617            if let Some(exp_idx) = text.find(['e', 'E']) {
618                let exponent = text[exp_idx..].to_string();
619                text.truncate(end.min(exp_idx));
620                text.push_str(&exponent);
621                return;
622            }
623        }
624        text.truncate(end);
625    }
626}
627
628fn format_string(
629    value: Value,
630    flags: FormatFlags,
631    width: Option<isize>,
632    precision: Option<isize>,
633) -> BuiltinResult<String> {
634    let mut text = value_to_string(&value)?;
635    if let Some(p) = precision {
636        if p >= 0 {
637            let mut chars = text.chars();
638            let mut truncated = String::with_capacity(text.len());
639            for _ in 0..(p as usize) {
640                if let Some(ch) = chars.next() {
641                    truncated.push(ch);
642                } else {
643                    break;
644                }
645            }
646            text = truncated;
647        }
648    }
649
650    apply_width(String::new(), String::new(), text, flags, width, false)
651}
652
653fn format_char(value: Value, flags: FormatFlags, width: Option<isize>) -> BuiltinResult<String> {
654    let ch = value_to_char(&value)?;
655    let text = ch.to_string();
656    apply_width(String::new(), String::new(), text, flags, width, false)
657}
658
659fn apply_width(
660    sign: String,
661    prefix: String,
662    digits: String,
663    flags: FormatFlags,
664    width: Option<isize>,
665    zero_pad: bool,
666) -> BuiltinResult<String> {
667    let mut result = String::new();
668    let sign_prefix_len = sign.len() + prefix.len();
669    let total_len = sign_prefix_len + digits.len();
670    let target_width = width.unwrap_or(0).max(0) as usize;
671
672    if target_width <= total_len {
673        result.push_str(&sign);
674        result.push_str(&prefix);
675        result.push_str(&digits);
676        return Ok(result);
677    }
678
679    let pad_len = target_width - total_len;
680    if flags.left_align {
681        result.push_str(&sign);
682        result.push_str(&prefix);
683        result.push_str(&digits);
684        for _ in 0..pad_len {
685            result.push(' ');
686        }
687        return Ok(result);
688    }
689
690    if zero_pad {
691        result.push_str(&sign);
692        result.push_str(&prefix);
693        for _ in 0..pad_len {
694            result.push('0');
695        }
696        result.push_str(&digits);
697    } else {
698        for _ in 0..pad_len {
699            result.push(' ');
700        }
701        result.push_str(&sign);
702        result.push_str(&prefix);
703        result.push_str(&digits);
704    }
705    Ok(result)
706}
707
708fn value_to_isize(value: &Value) -> BuiltinResult<isize> {
709    match value {
710        Value::Int(i) => Ok(i.to_i64().clamp(isize::MIN as i64, isize::MAX as i64) as isize),
711        Value::Num(n) => {
712            if !n.is_finite() {
713                return Err(format_error(
714                    "sprintf: width/precision specifier must be finite",
715                ));
716            }
717            Ok(n.trunc().clamp(isize::MIN as f64, isize::MAX as f64) as isize)
718        }
719        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
720        other => Err(format_error(format!(
721            "sprintf: width/precision specifier expects numeric value, got {other:?}"
722        ))),
723    }
724}
725
726fn value_to_i128(value: &Value) -> BuiltinResult<i128> {
727    match value {
728        Value::Int(i) => Ok(match i {
729            IntValue::I8(v) => i128::from(*v),
730            IntValue::I16(v) => i128::from(*v),
731            IntValue::I32(v) => i128::from(*v),
732            IntValue::I64(v) => i128::from(*v),
733            IntValue::U8(v) => i128::from(*v),
734            IntValue::U16(v) => i128::from(*v),
735            IntValue::U32(v) => i128::from(*v),
736            IntValue::U64(v) => i128::from(*v),
737        }),
738        Value::Num(n) => {
739            if !n.is_finite() {
740                return Err(format_error(
741                    "sprintf: numeric conversion requires finite input",
742                ));
743            }
744            Ok(n.trunc().clamp(i128::MIN as f64, i128::MAX as f64) as i128)
745        }
746        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
747        other => Err(format_error(format!(
748            "sprintf: expected numeric argument, got {other:?}"
749        ))),
750    }
751}
752
753fn value_to_u128(value: &Value) -> BuiltinResult<u128> {
754    match value {
755        Value::Int(i) => match i {
756            IntValue::I8(v) if *v < 0 => Err(format_error("sprintf: expected non-negative value")),
757            IntValue::I16(v) if *v < 0 => Err(format_error("sprintf: expected non-negative value")),
758            IntValue::I32(v) if *v < 0 => Err(format_error("sprintf: expected non-negative value")),
759            IntValue::I64(v) if *v < 0 => Err(format_error("sprintf: expected non-negative value")),
760            IntValue::I8(v) => Ok((*v) as u128),
761            IntValue::I16(v) => Ok((*v) as u128),
762            IntValue::I32(v) => Ok((*v) as u128),
763            IntValue::I64(v) => Ok((*v) as u128),
764            IntValue::U8(v) => Ok((*v) as u128),
765            IntValue::U16(v) => Ok((*v) as u128),
766            IntValue::U32(v) => Ok((*v) as u128),
767            IntValue::U64(v) => Ok((*v) as u128),
768        },
769        Value::Num(n) => {
770            if !n.is_finite() {
771                return Err(format_error(
772                    "sprintf: numeric conversion requires finite input",
773                ));
774            }
775            if *n < 0.0 {
776                return Err(format_error("sprintf: expected non-negative value"));
777            }
778            Ok(n.trunc().clamp(0.0, u128::MAX as f64) as u128)
779        }
780        Value::Bool(b) => Ok(if *b { 1 } else { 0 }),
781        other => Err(format_error(format!(
782            "sprintf: expected non-negative numeric value, got {other:?}"
783        ))),
784    }
785}
786
787fn value_to_f64(value: &Value) -> BuiltinResult<f64> {
788    match value {
789        Value::Num(n) => Ok(*n),
790        Value::Int(i) => Ok(i.to_f64()),
791        Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
792        other => Err(format_error(format!(
793            "sprintf: expected numeric value, got {other:?}"
794        ))),
795    }
796}
797
798fn value_to_string(value: &Value) -> BuiltinResult<String> {
799    match value {
800        Value::String(s) => Ok(s.clone()),
801        Value::CharArray(ca) => {
802            let mut s = String::with_capacity(ca.data.len());
803            for ch in &ca.data {
804                s.push(*ch);
805            }
806            Ok(s)
807        }
808        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
809        Value::Num(n) => Ok(Value::Num(*n).to_string()),
810        Value::Int(i) => Ok(i.to_i64().to_string()),
811        Value::Bool(b) => Ok(if *b { "true" } else { "false" }.to_string()),
812        Value::Complex(re, im) => Ok(Value::Complex(*re, *im).to_string()),
813        other => Err(format_error(format!(
814            "sprintf: expected text or scalar value for %s conversion, got {other:?}"
815        ))),
816    }
817}
818
819fn value_to_char(value: &Value) -> BuiltinResult<char> {
820    match value {
821        Value::String(s) => s.chars().next().ok_or_else(|| {
822            format_error("sprintf: %c conversion requires non-empty character input")
823        }),
824        Value::CharArray(ca) => ca
825            .data
826            .first()
827            .copied()
828            .ok_or_else(|| format_error("sprintf: %c conversion requires non-empty char input")),
829        Value::Num(n) => {
830            if !n.is_finite() {
831                return Err(format_error(
832                    "sprintf: %c conversion needs finite numeric value",
833                ));
834            }
835            let code = n.trunc() as u32;
836            std::char::from_u32(code)
837                .ok_or_else(|| format_error("sprintf: numeric value outside valid character range"))
838        }
839        Value::Int(i) => {
840            let code = i.to_i64();
841            if code < 0 {
842                return Err(format_error("sprintf: negative value for %c conversion"));
843            }
844            std::char::from_u32(code as u32)
845                .ok_or_else(|| format_error("sprintf: numeric value outside valid character range"))
846        }
847        other => Err(format_error(format!(
848            "sprintf: %c conversion expects character data, got {other:?}"
849        ))),
850    }
851}
852
853fn to_base_string(mut value: u128, base: u32, uppercase: bool) -> String {
854    if value == 0 {
855        return "0".to_string();
856    }
857    let mut buf = Vec::new();
858    while value > 0 {
859        let digit = (value % base as u128) as u8;
860        let ch = match digit {
861            0..=9 => b'0' + digit,
862            _ => {
863                if uppercase {
864                    b'A' + (digit - 10)
865                } else {
866                    b'a' + (digit - 10)
867                }
868            }
869        };
870        buf.push(ch as char);
871        value /= base as u128;
872    }
873    buf.iter().rev().collect()
874}
875
876fn group_decimal_digits(digits: &str) -> String {
877    if digits.len() <= 3 {
878        return digits.to_string();
879    }
880    let chars: Vec<char> = digits.chars().collect();
881    let mut out = String::with_capacity(digits.len() + (digits.len() - 1) / 3);
882    for (idx, ch) in chars.iter().enumerate() {
883        if idx > 0 && (chars.len() - idx).is_multiple_of(3) {
884            out.push(',');
885        }
886        out.push(*ch);
887    }
888    out
889}
890
891fn group_float_mantissa(text: &str) -> String {
892    let (mantissa, exponent) = match text.find(['e', 'E']) {
893        Some(idx) => (&text[..idx], &text[idx..]),
894        None => (text, ""),
895    };
896
897    let mut parts = mantissa.splitn(2, '.');
898    let int_part = parts.next().unwrap_or_default();
899    let frac_part = parts.next();
900    let grouped_int = group_decimal_digits(int_part);
901
902    let mut out = grouped_int;
903    if let Some(frac) = frac_part {
904        out.push('.');
905        out.push_str(frac);
906    }
907    out.push_str(exponent);
908    out
909}
910
911/// Extract a printf-style format string from a MATLAB value, validating that it
912/// is a character row vector or string scalar.
913pub fn extract_format_string(value: &Value, context: &str) -> BuiltinResult<String> {
914    match value {
915        Value::String(s) => Ok(s.clone()),
916        Value::CharArray(ca) => {
917            if ca.rows != 1 {
918                return Err(format_error(format!(
919                    "{context}: formatSpec must be a character row vector or string scalar"
920                )));
921            }
922            Ok(ca.data.iter().collect())
923        }
924        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
925        _ => Err(format_error(format!(
926            "{context}: formatSpec must be a character row vector or string scalar"
927        ))),
928    }
929}
930
931/// Decode MATLAB-compatible escape sequences within a format specification.
932pub fn decode_escape_sequences(context: &str, input: &str) -> BuiltinResult<String> {
933    let mut result = String::with_capacity(input.len());
934    let mut chars = input.chars().peekable();
935    while let Some(ch) = chars.next() {
936        if ch != '\\' {
937            result.push(ch);
938            continue;
939        }
940        let Some(next) = chars.next() else {
941            result.push('\\');
942            break;
943        };
944        match next {
945            '\\' => result.push('\\'),
946            'a' => result.push('\u{0007}'),
947            'b' => result.push('\u{0008}'),
948            'f' => result.push('\u{000C}'),
949            'n' => result.push('\n'),
950            'r' => result.push('\r'),
951            't' => result.push('\t'),
952            'v' => result.push('\u{000B}'),
953            'x' => {
954                let mut hex = String::new();
955                for _ in 0..2 {
956                    match chars.peek().copied() {
957                        Some(c) if c.is_ascii_hexdigit() => {
958                            hex.push(chars.next().unwrap());
959                        }
960                        _ => break,
961                    }
962                }
963                if hex.is_empty() {
964                    result.push('\\');
965                    result.push('x');
966                } else {
967                    let value = u32::from_str_radix(&hex, 16).map_err(|_| {
968                        format_error(format!("{context}: invalid hexadecimal escape \\x{hex}"))
969                    })?;
970                    if let Some(chr) = char::from_u32(value) {
971                        result.push(chr);
972                    } else {
973                        return Err(format_error(format!(
974                            "{context}: \\x{hex} escape outside valid Unicode range"
975                        )));
976                    }
977                }
978            }
979            '0'..='7' => {
980                let mut oct = String::new();
981                oct.push(next);
982                for _ in 0..2 {
983                    match chars.peek().copied() {
984                        Some(c) if ('0'..='7').contains(&c) => {
985                            oct.push(chars.next().unwrap());
986                        }
987                        _ => break,
988                    }
989                }
990                let value = u32::from_str_radix(&oct, 8).map_err(|_| {
991                    format_error(format!("{context}: invalid octal escape \\{oct}"))
992                })?;
993                if let Some(chr) = char::from_u32(value) {
994                    result.push(chr);
995                } else {
996                    return Err(format_error(format!(
997                        "{context}: \\{oct} escape outside valid Unicode range"
998                    )));
999                }
1000            }
1001            other => {
1002                result.push('\\');
1003                result.push(other);
1004            }
1005        }
1006    }
1007    Ok(result)
1008}
1009
1010/// Flatten MATLAB argument values into a linear vector suitable for repeated
1011/// printf-style formatting. Arrays are traversed in column-major order and GPU
1012/// tensors are gathered back to the host.
1013pub async fn flatten_arguments(args: &[Value], context: &str) -> BuiltinResult<Vec<Value>> {
1014    let mut flattened = Vec::new();
1015    for value in args {
1016        let gathered = gather_if_needed_async(value)
1017            .await
1018            .map_err(|flow| map_control_flow_with_context(flow, context))?;
1019        flatten_value(gathered, &mut flattened, context).await?;
1020    }
1021    Ok(flattened)
1022}
1023
1024#[async_recursion::async_recursion(?Send)]
1025async fn flatten_value(value: Value, output: &mut Vec<Value>, context: &str) -> BuiltinResult<()> {
1026    match value {
1027        Value::Num(_)
1028        | Value::Int(_)
1029        | Value::Bool(_)
1030        | Value::String(_)
1031        | Value::Complex(_, _) => {
1032            output.push(value);
1033        }
1034        Value::Tensor(tensor) => {
1035            for &elem in &tensor.data {
1036                output.push(Value::Num(elem));
1037            }
1038        }
1039        Value::ComplexTensor(tensor) => {
1040            for &(re, im) in &tensor.data {
1041                output.push(Value::Complex(re, im));
1042            }
1043        }
1044        Value::LogicalArray(LogicalArray { data, .. }) => {
1045            for byte in data {
1046                output.push(Value::Bool(byte != 0));
1047            }
1048        }
1049        Value::StringArray(StringArray { data, .. }) => {
1050            for s in data {
1051                output.push(Value::String(s));
1052            }
1053        }
1054        Value::CharArray(ca) => {
1055            if ca.rows == 1 {
1056                output.push(Value::String(ca.data.iter().collect()));
1057            } else {
1058                for row in 0..ca.rows {
1059                    let mut line = String::with_capacity(ca.cols);
1060                    for col in 0..ca.cols {
1061                        line.push(ca.data[row * ca.cols + col]);
1062                    }
1063                    output.push(Value::String(line));
1064                }
1065            }
1066        }
1067        Value::Cell(cell) => {
1068            for col in 0..cell.cols {
1069                for row in 0..cell.rows {
1070                    let idx = row * cell.cols + col;
1071                    let inner = (*cell.data[idx]).clone();
1072                    let gathered = gather_if_needed_async(&inner)
1073                        .await
1074                        .map_err(|flow| map_control_flow_with_context(flow, context))?;
1075                    flatten_value(gathered, output, context).await?;
1076                }
1077            }
1078        }
1079        Value::GpuTensor(handle) => {
1080            let gathered = gather_if_needed_async(&Value::GpuTensor(handle))
1081                .await
1082                .map_err(|flow| map_control_flow_with_context(flow, context))?;
1083            flatten_value(gathered, output, context).await?;
1084        }
1085        Value::OutputList(values) => {
1086            for value in values {
1087                flatten_value(value, output, context).await?;
1088            }
1089        }
1090        Value::MException(_)
1091        | Value::HandleObject(_)
1092        | Value::Listener(_)
1093        | Value::Object(_)
1094        | Value::Struct(_)
1095        | Value::FunctionHandle(_)
1096        | Value::Closure(_)
1097        | Value::ClassRef(_) => {
1098            return Err(format_error(format!(
1099                "{context}: unsupported argument type"
1100            )));
1101        }
1102    }
1103    Ok(())
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108    use super::*;
1109
1110    #[test]
1111    fn format_variadic_supports_thousands_grouping_flag() {
1112        let out = format_variadic("%'d %'.2f", &[Value::Num(1234567.0), Value::Num(12345.5)])
1113            .expect("grouped formatting should succeed");
1114        assert_eq!(out, "1,234,567 12,345.50");
1115    }
1116
1117    #[test]
1118    fn format_variadic_consumes_i_flag_without_error() {
1119        let out = format_variadic("%Id", &[Value::Int(IntValue::I32(42))])
1120            .expect("I flag should be accepted as compatibility no-op");
1121        assert_eq!(out, "42");
1122    }
1123}