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