Skip to main content

dear_imgui_rs/
numeric_format.rs

1use std::borrow::Cow;
2use std::marker::PhantomData;
3use std::ops::Range;
4use std::str::FromStr;
5use thiserror::Error;
6
7use crate::internal::{DataType, DataTypeKind};
8
9const MAX_FORMAT_BYTES: usize = 4096;
10const MAX_DIRECTIVE_BYTES: usize = 30;
11const MAX_FIELD_WIDTH: u32 = 31;
12const MAX_PRECISION: u32 = 99;
13
14/// A validated C-style format for one Dear ImGui numeric value.
15///
16/// The value type is part of the format type, so a format validated for `f32`
17/// cannot be passed to an integer widget. Borrowed strings remain zero-copy,
18/// while owned strings remain stable after validation.
19///
20/// ```compile_fail
21/// use dear_imgui_rs::{Context, NumericFormat};
22///
23/// let mut context = Context::create();
24/// let ui = context.frame();
25/// let integer_format = NumericFormat::<u32>::new("%u").unwrap();
26/// let _ = ui
27///     .slider_config("value", 0.0_f32, 1.0_f32)
28///     .display_format(integer_format);
29/// ```
30///
31/// ```compile_fail
32/// use dear_imgui_rs::Context;
33///
34/// let mut context = Context::create();
35/// let ui = context.frame();
36/// let _ = ui
37///     .slider_config("value", 0.0_f32, 1.0_f32)
38///     .display_format("%.2f");
39/// ```
40#[derive(Clone, Debug, PartialEq)]
41pub struct NumericFormat<'a, T> {
42    storage: Cow<'a, str>,
43    marker: PhantomData<fn() -> T>,
44}
45
46impl<'a, T> NumericFormat<'a, T>
47where
48    T: DataTypeKind,
49{
50    /// Validates and retains a numeric format.
51    ///
52    /// Safe formats may contain ordinary UTF-8 decoration, escaped percent
53    /// signs (`%%`), and at most one conversion matching `T`. The portable `ll` and MSVC
54    /// `I64` modifiers are normalized for the compilation target. Width is
55    /// limited to 31, precision to 99, and the complete UTF-8 string to 4096
56    /// bytes so downstream native parsing remains bounded.
57    ///
58    /// ```
59    /// use dear_imgui_rs::NumericFormat;
60    ///
61    /// let percent = NumericFormat::<f32>::new("%.1f%%")?;
62    /// assert_eq!(percent.as_str(), "%.1f%%");
63    /// assert!(NumericFormat::<f32>::new("%s").is_err());
64    /// # Ok::<(), dear_imgui_rs::NumericFormatError>(())
65    /// ```
66    pub fn new(storage: impl Into<Cow<'a, str>>) -> Result<Self, NumericFormatError> {
67        let storage = storage.into();
68        validate_format_length(&storage)?;
69        let storage = normalize_wide_integer_length::<T>(storage);
70        validate_numeric_format::<T>(&storage)?;
71        Ok(Self {
72            storage,
73            marker: PhantomData,
74        })
75    }
76
77    /// Creates a numeric format without validation.
78    ///
79    /// # Safety
80    ///
81    /// The format must be valid for the exact C variadic argument represented
82    /// by `T::KIND`. It must consume at most one value argument, must not use
83    /// dynamic width or precision, positional arguments, `%n`, or any other
84    /// conversion that consumes a different argument type. Width must not exceed
85    /// 31, precision must not exceed 99, flags must be valid for the conversion,
86    /// and the complete string must not exceed 4096 bytes. Violating this contract
87    /// can cause undefined behavior, including arbitrary memory writes.
88    pub unsafe fn new_unchecked(storage: impl Into<Cow<'a, str>>) -> Self {
89        Self {
90            storage: storage.into(),
91            marker: PhantomData,
92        }
93    }
94
95    /// Returns the validated, target-normalized format string.
96    pub fn as_str(&self) -> &str {
97        &self.storage
98    }
99
100    /// Borrows this format without revalidating it.
101    pub fn borrowed(&self) -> NumericFormat<'_, T> {
102        NumericFormat {
103            storage: Cow::Borrowed(self.as_str()),
104            marker: PhantomData,
105        }
106    }
107
108    /// Converts this format into an owned value that can be stored indefinitely.
109    pub fn into_owned(self) -> NumericFormat<'static, T> {
110        NumericFormat {
111            storage: Cow::Owned(self.storage.into_owned()),
112            marker: PhantomData,
113        }
114    }
115
116    /// Returns the target-normalized string storage.
117    pub fn into_inner(self) -> Cow<'a, str> {
118        self.storage
119    }
120}
121
122impl<T> AsRef<str> for NumericFormat<'_, T>
123where
124    T: DataTypeKind,
125{
126    fn as_ref(&self) -> &str {
127        self.as_str()
128    }
129}
130
131impl<'a, T> TryFrom<&'a str> for NumericFormat<'a, T>
132where
133    T: DataTypeKind,
134{
135    type Error = NumericFormatError;
136
137    fn try_from(value: &'a str) -> Result<Self, Self::Error> {
138        Self::new(value)
139    }
140}
141
142impl<T> TryFrom<String> for NumericFormat<'static, T>
143where
144    T: DataTypeKind,
145{
146    type Error = NumericFormatError;
147
148    fn try_from(value: String) -> Result<Self, Self::Error> {
149        Self::new(value)
150    }
151}
152
153impl<T> FromStr for NumericFormat<'static, T>
154where
155    T: DataTypeKind,
156{
157    type Err = NumericFormatError;
158
159    fn from_str(value: &str) -> Result<Self, Self::Err> {
160        Self::new(value.to_owned())
161    }
162}
163
164/// Describes why a C-style numeric format was rejected.
165#[derive(Clone, Debug, Eq, Error, PartialEq)]
166#[non_exhaustive]
167pub enum NumericFormatError {
168    /// The complete UTF-8 format exceeds the bounded native parsing contract.
169    #[error("numeric format is {length} UTF-8 bytes; at most {maximum} bytes are supported")]
170    FormatTooLong {
171        byte_offset: usize,
172        length: usize,
173        maximum: usize,
174    },
175    /// The string contains a NUL byte and cannot be passed as one C string.
176    #[error("numeric format contains a NUL byte at byte {byte_offset}")]
177    InteriorNul { byte_offset: usize },
178    /// A `%` directive is incomplete.
179    #[error("numeric format has an incomplete directive at byte {byte_offset}")]
180    UnterminatedDirective { byte_offset: usize },
181    /// More than one directive would consume a value argument.
182    #[error(
183        "numeric format requests more than Dear ImGui's one value argument at byte {byte_offset}"
184    )]
185    MultipleConversions { byte_offset: usize },
186    /// `*` requests another variadic width or precision argument.
187    #[error("numeric format requests a dynamic variadic argument at byte {byte_offset}")]
188    DynamicArgument { byte_offset: usize },
189    /// A positional argument such as `%1$d` was requested.
190    #[error("numeric format uses an unsupported positional argument at byte {byte_offset}")]
191    PositionalArgument { byte_offset: usize },
192    /// The directive uses a non-portable or unsupported flag.
193    #[error("numeric format uses unsupported flag `{flag}` at byte {byte_offset}")]
194    UnsupportedFlag { byte_offset: usize, flag: char },
195    /// The flag is defined for printf but not for this numeric conversion.
196    #[error(
197        "numeric format flag `{flag}` is incompatible with `%{conversion}` at byte {byte_offset}"
198    )]
199    IncompatibleFlag {
200        byte_offset: usize,
201        flag: char,
202        conversion: char,
203    },
204    /// The requested field width exceeds the bounded native parsing contract.
205    #[error(
206        "numeric format width {width} at byte {byte_offset} exceeds the supported maximum {maximum}"
207    )]
208    WidthTooLarge {
209        byte_offset: usize,
210        width: u32,
211        maximum: u32,
212    },
213    /// The requested precision exceeds the bounded native parsing contract.
214    #[error(
215        "numeric format precision {precision} at byte {byte_offset} exceeds the supported maximum {maximum}"
216    )]
217    PrecisionTooLarge {
218        byte_offset: usize,
219        precision: u32,
220        maximum: u32,
221    },
222    /// The directive uses a length modifier that does not match the C carrier type.
223    #[error(
224        "numeric format uses a length modifier that does not match its value type at byte {byte_offset}"
225    )]
226    UnsupportedLength { byte_offset: usize },
227    /// The directive is not a supported numeric conversion.
228    #[error("numeric format uses unsupported conversion `%{conversion}` at byte {byte_offset}")]
229    UnsupportedConversion {
230        byte_offset: usize,
231        conversion: char,
232    },
233    /// The conversion is numeric but does not match the signedness or category of `T`.
234    #[error("numeric format conversion does not match its value type at byte {byte_offset}")]
235    TypeMismatch { byte_offset: usize },
236    /// Dear ImGui copies directives into a fixed 32-byte stack buffer.
237    #[error(
238        "numeric format directive at byte {byte_offset} requires {length} bytes on a supported target; Dear ImGui supports at most {maximum}",
239        maximum = MAX_DIRECTIVE_BYTES
240    )]
241    DirectiveTooLong { byte_offset: usize, length: usize },
242}
243
244impl NumericFormatError {
245    /// Returns the byte offset where validation failed.
246    pub fn byte_offset(&self) -> usize {
247        match *self {
248            Self::FormatTooLong { byte_offset, .. }
249            | Self::InteriorNul { byte_offset }
250            | Self::UnterminatedDirective { byte_offset }
251            | Self::MultipleConversions { byte_offset }
252            | Self::DynamicArgument { byte_offset }
253            | Self::PositionalArgument { byte_offset }
254            | Self::UnsupportedFlag { byte_offset, .. }
255            | Self::IncompatibleFlag { byte_offset, .. }
256            | Self::WidthTooLarge { byte_offset, .. }
257            | Self::PrecisionTooLarge { byte_offset, .. }
258            | Self::UnsupportedLength { byte_offset }
259            | Self::UnsupportedConversion { byte_offset, .. }
260            | Self::TypeMismatch { byte_offset }
261            | Self::DirectiveTooLong { byte_offset, .. } => byte_offset,
262        }
263    }
264}
265
266fn validate_numeric_format<T>(format: &str) -> Result<(), NumericFormatError>
267where
268    T: DataTypeKind,
269{
270    validate_numeric_format_for_data_type(format, T::KIND)
271}
272
273fn validate_format_length(format: &str) -> Result<(), NumericFormatError> {
274    if format.len() > MAX_FORMAT_BYTES {
275        Err(NumericFormatError::FormatTooLong {
276            byte_offset: MAX_FORMAT_BYTES,
277            length: format.len(),
278            maximum: MAX_FORMAT_BYTES,
279        })
280    } else {
281        Ok(())
282    }
283}
284
285fn normalize_wide_integer_length<'a, T>(storage: Cow<'a, str>) -> Cow<'a, str>
286where
287    T: DataTypeKind,
288{
289    if !matches!(T::KIND, DataType::I64 | DataType::U64) {
290        return storage;
291    }
292
293    let Some(length_range) = find_wide_length_modifier(&storage) else {
294        return storage;
295    };
296    let current = &storage[length_range.clone()];
297    let target = if cfg!(target_env = "msvc") {
298        "I64"
299    } else {
300        "ll"
301    };
302    if current == target {
303        return storage;
304    }
305
306    let mut normalized = storage.into_owned();
307    normalized.replace_range(length_range, target);
308    Cow::Owned(normalized)
309}
310
311fn find_wide_length_modifier(format: &str) -> Option<Range<usize>> {
312    let bytes = format.as_bytes();
313    let mut byte_offset = 0;
314
315    while byte_offset < bytes.len() {
316        if bytes[byte_offset] != b'%' {
317            byte_offset += 1;
318            continue;
319        }
320        byte_offset += 1;
321        if byte_offset < bytes.len() && bytes[byte_offset] == b'%' {
322            byte_offset += 1;
323            continue;
324        }
325
326        while byte_offset < bytes.len()
327            && matches!(bytes[byte_offset], b'-' | b'+' | b' ' | b'#' | b'0')
328        {
329            byte_offset += 1;
330        }
331        if byte_offset < bytes.len() && bytes[byte_offset] == b'*' {
332            byte_offset += 1;
333        } else {
334            while byte_offset < bytes.len() && bytes[byte_offset].is_ascii_digit() {
335                byte_offset += 1;
336            }
337        }
338        if byte_offset < bytes.len() && bytes[byte_offset] == b'.' {
339            byte_offset += 1;
340            if byte_offset < bytes.len() && bytes[byte_offset] == b'*' {
341                byte_offset += 1;
342            } else {
343                while byte_offset < bytes.len() && bytes[byte_offset].is_ascii_digit() {
344                    byte_offset += 1;
345                }
346            }
347        }
348
349        if bytes[byte_offset..].starts_with(b"ll") {
350            return Some(byte_offset..byte_offset + 2);
351        }
352        if bytes[byte_offset..].starts_with(b"I64") {
353            return Some(byte_offset..byte_offset + 3);
354        }
355        return None;
356    }
357
358    None
359}
360
361#[derive(Copy, Clone, Debug, Eq, PartialEq)]
362enum ArgumentKind {
363    SignedInteger,
364    UnsignedInteger,
365    SignedWideInteger,
366    UnsignedWideInteger,
367    Double,
368}
369
370impl ArgumentKind {
371    fn from_data_type(data_type: DataType) -> Self {
372        match data_type {
373            DataType::I8 | DataType::I16 | DataType::I32 => Self::SignedInteger,
374            DataType::U8 | DataType::U16 | DataType::U32 => Self::UnsignedInteger,
375            DataType::I64 => Self::SignedWideInteger,
376            DataType::U64 => Self::UnsignedWideInteger,
377            DataType::F32 | DataType::F64 => Self::Double,
378        }
379    }
380
381    fn accepts_conversion(self, conversion: u8) -> bool {
382        match self {
383            Self::SignedInteger | Self::SignedWideInteger => matches!(conversion, b'd' | b'i'),
384            Self::UnsignedInteger | Self::UnsignedWideInteger => {
385                matches!(conversion, b'u' | b'o' | b'x' | b'X')
386            }
387            Self::Double => matches!(conversion, b'e' | b'E' | b'f' | b'F' | b'g' | b'G'),
388        }
389    }
390
391    fn accepts_length(self, length: LengthModifier) -> bool {
392        match self {
393            Self::SignedInteger | Self::UnsignedInteger => length == LengthModifier::None,
394            Self::SignedWideInteger | Self::UnsignedWideInteger => {
395                length == LengthModifier::LongLong
396                    || cfg!(target_env = "msvc") && length == LengthModifier::MsvcI64
397            }
398            Self::Double => matches!(length, LengthModifier::None | LengthModifier::Long),
399        }
400    }
401}
402
403#[derive(Copy, Clone, Debug, Eq, PartialEq)]
404enum LengthModifier {
405    None,
406    Char,
407    Short,
408    Long,
409    LongLong,
410    IntMax,
411    Size,
412    PtrDiff,
413    LongDouble,
414    MsvcI32,
415    MsvcI64,
416}
417
418#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
419struct DirectiveFlags {
420    alternate: Option<usize>,
421    explicit_sign: Option<usize>,
422    leading_space: Option<usize>,
423}
424
425impl DirectiveFlags {
426    fn record(&mut self, flag: u8, byte_offset: usize) {
427        match flag {
428            b'#' => {
429                let _ = self.alternate.get_or_insert(byte_offset);
430            }
431            b'+' => {
432                let _ = self.explicit_sign.get_or_insert(byte_offset);
433            }
434            b' ' => {
435                let _ = self.leading_space.get_or_insert(byte_offset);
436            }
437            b'-' | b'0' => {}
438            _ => {}
439        }
440    }
441
442    fn first_incompatible(self, conversion: u8) -> Option<(usize, u8)> {
443        let mut incompatible = [None; 3];
444        if matches!(conversion, b'd' | b'i' | b'u') {
445            incompatible[0] = self.alternate.map(|offset| (offset, b'#'));
446        }
447        if matches!(conversion, b'u' | b'o' | b'x' | b'X') {
448            incompatible[1] = self.explicit_sign.map(|offset| (offset, b'+'));
449            incompatible[2] = self.leading_space.map(|offset| (offset, b' '));
450        }
451        incompatible
452            .into_iter()
453            .flatten()
454            .min_by_key(|(offset, _)| *offset)
455    }
456}
457
458#[derive(Copy, Clone, Debug, Eq, PartialEq)]
459enum DecimalComponent {
460    Width,
461    Precision,
462}
463
464impl DecimalComponent {
465    fn maximum(self) -> u32 {
466        match self {
467            Self::Width => MAX_FIELD_WIDTH,
468            Self::Precision => MAX_PRECISION,
469        }
470    }
471
472    fn too_large(self, byte_offset: usize, value: u32) -> NumericFormatError {
473        match self {
474            Self::Width => NumericFormatError::WidthTooLarge {
475                byte_offset,
476                width: value,
477                maximum: self.maximum(),
478            },
479            Self::Precision => NumericFormatError::PrecisionTooLarge {
480                byte_offset,
481                precision: value,
482                maximum: self.maximum(),
483            },
484        }
485    }
486}
487
488fn validate_numeric_format_for_data_type(
489    format: &str,
490    data_type: DataType,
491) -> Result<(), NumericFormatError> {
492    validate_format_length(format)?;
493    let bytes = format.as_bytes();
494    if let Some(byte_offset) = bytes.iter().position(|byte| *byte == 0) {
495        return Err(NumericFormatError::InteriorNul { byte_offset });
496    }
497
498    let argument_kind = ArgumentKind::from_data_type(data_type);
499    let mut byte_offset = 0;
500    let mut value_conversions = 0;
501
502    while byte_offset < bytes.len() {
503        if bytes[byte_offset] != b'%' {
504            byte_offset += 1;
505            continue;
506        }
507
508        let conversion_start = byte_offset;
509        byte_offset += 1;
510        if byte_offset == bytes.len() {
511            return Err(NumericFormatError::UnterminatedDirective {
512                byte_offset: conversion_start,
513            });
514        }
515        if bytes[byte_offset] == b'%' {
516            byte_offset += 1;
517            continue;
518        }
519
520        value_conversions += 1;
521        if value_conversions > 1 {
522            return Err(NumericFormatError::MultipleConversions {
523                byte_offset: conversion_start,
524            });
525        }
526
527        let mut flags = DirectiveFlags::default();
528        while byte_offset < bytes.len()
529            && matches!(bytes[byte_offset], b'-' | b'+' | b' ' | b'#' | b'0')
530        {
531            flags.record(bytes[byte_offset], byte_offset);
532            byte_offset += 1;
533        }
534        reject_unsupported_flag(bytes, byte_offset)?;
535
536        if byte_offset < bytes.len() && bytes[byte_offset] == b'*' {
537            return Err(NumericFormatError::DynamicArgument { byte_offset });
538        }
539        parse_bounded_decimal(bytes, &mut byte_offset, DecimalComponent::Width)?;
540        reject_positional_argument(bytes, byte_offset)?;
541
542        if byte_offset < bytes.len() && bytes[byte_offset] == b'.' {
543            byte_offset += 1;
544            if byte_offset < bytes.len() && bytes[byte_offset] == b'*' {
545                return Err(NumericFormatError::DynamicArgument { byte_offset });
546            }
547            parse_bounded_decimal(bytes, &mut byte_offset, DecimalComponent::Precision)?;
548            reject_positional_argument(bytes, byte_offset)?;
549        }
550
551        let length_offset = byte_offset;
552        let (length, next_offset) = parse_length_modifier(bytes, byte_offset);
553        byte_offset = next_offset;
554        if byte_offset == bytes.len() {
555            return Err(NumericFormatError::UnterminatedDirective {
556                byte_offset: conversion_start,
557            });
558        }
559        if length == LengthModifier::LongLong {
560            let portable_format_length = format.len().saturating_add(1);
561            if portable_format_length > MAX_FORMAT_BYTES {
562                return Err(NumericFormatError::FormatTooLong {
563                    byte_offset: MAX_FORMAT_BYTES,
564                    length: portable_format_length,
565                    maximum: MAX_FORMAT_BYTES,
566                });
567            }
568        }
569
570        let conversion = bytes[byte_offset];
571        byte_offset += 1;
572        let directive_length = byte_offset - conversion_start;
573        let portable_directive_length = if length == LengthModifier::LongLong {
574            directive_length.saturating_add(1)
575        } else {
576            directive_length
577        };
578        if portable_directive_length > MAX_DIRECTIVE_BYTES {
579            return Err(NumericFormatError::DirectiveTooLong {
580                byte_offset: conversion_start,
581                length: portable_directive_length,
582            });
583        }
584        if !is_supported_numeric_conversion(conversion) {
585            return Err(NumericFormatError::UnsupportedConversion {
586                byte_offset: conversion_start,
587                conversion: char::from(conversion),
588            });
589        }
590        if !argument_kind.accepts_conversion(conversion) {
591            return Err(NumericFormatError::TypeMismatch {
592                byte_offset: conversion_start,
593            });
594        }
595        if !argument_kind.accepts_length(length) {
596            return Err(NumericFormatError::UnsupportedLength {
597                byte_offset: length_offset,
598            });
599        }
600        if let Some((flag_offset, flag)) = flags.first_incompatible(conversion) {
601            return Err(NumericFormatError::IncompatibleFlag {
602                byte_offset: flag_offset,
603                flag: char::from(flag),
604                conversion: char::from(conversion),
605            });
606        }
607    }
608
609    Ok(())
610}
611
612fn parse_bounded_decimal(
613    bytes: &[u8],
614    byte_offset: &mut usize,
615    component: DecimalComponent,
616) -> Result<(), NumericFormatError> {
617    let start = *byte_offset;
618    let mut value = 0_u32;
619
620    while *byte_offset < bytes.len() && bytes[*byte_offset].is_ascii_digit() {
621        let digit = u32::from(bytes[*byte_offset] - b'0');
622        value = value
623            .checked_mul(10)
624            .and_then(|current| current.checked_add(digit))
625            .ok_or_else(|| component.too_large(start, u32::MAX))?;
626        if value > component.maximum() {
627            return Err(component.too_large(start, value));
628        }
629        *byte_offset += 1;
630    }
631
632    Ok(())
633}
634
635fn reject_unsupported_flag(bytes: &[u8], byte_offset: usize) -> Result<(), NumericFormatError> {
636    if byte_offset < bytes.len() && matches!(bytes[byte_offset], b'\'' | b'_') {
637        Err(NumericFormatError::UnsupportedFlag {
638            byte_offset,
639            flag: char::from(bytes[byte_offset]),
640        })
641    } else {
642        Ok(())
643    }
644}
645
646fn reject_positional_argument(bytes: &[u8], byte_offset: usize) -> Result<(), NumericFormatError> {
647    if byte_offset < bytes.len() && bytes[byte_offset] == b'$' {
648        Err(NumericFormatError::PositionalArgument { byte_offset })
649    } else {
650        Ok(())
651    }
652}
653
654fn parse_length_modifier(bytes: &[u8], byte_offset: usize) -> (LengthModifier, usize) {
655    let remaining = &bytes[byte_offset..];
656    if remaining.starts_with(b"hh") {
657        (LengthModifier::Char, byte_offset + 2)
658    } else if remaining.starts_with(b"ll") {
659        (LengthModifier::LongLong, byte_offset + 2)
660    } else if remaining.starts_with(b"I32") {
661        (LengthModifier::MsvcI32, byte_offset + 3)
662    } else if remaining.starts_with(b"I64") {
663        (LengthModifier::MsvcI64, byte_offset + 3)
664    } else if let Some(first) = remaining.first() {
665        let length = match first {
666            b'h' => LengthModifier::Short,
667            b'l' => LengthModifier::Long,
668            b'j' => LengthModifier::IntMax,
669            b'z' => LengthModifier::Size,
670            b't' => LengthModifier::PtrDiff,
671            b'L' => LengthModifier::LongDouble,
672            _ => return (LengthModifier::None, byte_offset),
673        };
674        (length, byte_offset + 1)
675    } else {
676        (LengthModifier::None, byte_offset)
677    }
678}
679
680fn is_supported_numeric_conversion(conversion: u8) -> bool {
681    matches!(
682        conversion,
683        b'd' | b'i' | b'u' | b'o' | b'x' | b'X' | b'e' | b'E' | b'f' | b'F' | b'g' | b'G'
684    )
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn typed_formats_retain_borrowed_and_owned_storage() {
693        let borrowed = NumericFormat::<f32>::new("%.3f").unwrap();
694        assert_eq!(borrowed.as_str(), "%.3f");
695        assert!(matches!(borrowed.clone().into_inner(), Cow::Borrowed(_)));
696
697        let owned = NumericFormat::<u32>::new(String::from("0x%08X")).unwrap();
698        assert_eq!(owned.as_str(), "0x%08X");
699        assert!(matches!(owned.clone().into_inner(), Cow::Owned(_)));
700        assert_eq!(owned.into_inner().as_ref(), "0x%08X");
701    }
702
703    #[test]
704    fn formats_can_be_reborrowed_or_made_owned_without_revalidation() {
705        let text = String::from("%.2f");
706        let borrowed = NumericFormat::<f32>::new(text.as_str()).unwrap();
707        assert_eq!(borrowed.borrowed().as_str(), "%.2f");
708
709        let owned = borrowed.into_owned();
710        drop(text);
711        assert_eq!(owned.as_str(), "%.2f");
712    }
713
714    #[test]
715    fn floating_formats_allow_one_conversion_and_literal_percent_signs() {
716        for format in [
717            "%.3f",
718            "%+08.2e ms",
719            "%.0f%%",
720            "literal %% only",
721            "plain text",
722            "temperature: %.2f °C",
723        ] {
724            assert_eq!(validate_numeric_format::<f32>(format), Ok(()), "{format}");
725            assert_eq!(validate_numeric_format::<f64>(format), Ok(()), "{format}");
726        }
727        assert_eq!(validate_numeric_format::<f64>("%lf"), Ok(()));
728    }
729
730    #[test]
731    fn integer_formats_match_signedness_and_carrier_width() {
732        for format in ["%d", "%08i", "value %d", "%+d", "% d"] {
733            assert_eq!(validate_numeric_format::<i32>(format), Ok(()), "{format}");
734        }
735        for format in ["%u", "%08X", "0%o", "%#x", "%#o"] {
736            assert_eq!(validate_numeric_format::<u32>(format), Ok(()), "{format}");
737        }
738        let signed = NumericFormat::<i64>::new("%lld").unwrap();
739        let unsigned = NumericFormat::<u64>::new("0x%016I64X").unwrap();
740        if cfg!(target_env = "msvc") {
741            assert_eq!(signed.as_str(), "%I64d");
742            assert_eq!(unsigned.as_str(), "0x%016I64X");
743        } else {
744            assert_eq!(signed.as_str(), "%lld");
745            assert_eq!(unsigned.as_str(), "0x%016llX");
746        }
747    }
748
749    #[test]
750    fn formats_cannot_consume_missing_or_mistyped_arguments() {
751        for format in [
752            "%s", "%n", "%p", "%c", "%a", "%*f", "%.*f", "%2$f", "%f %f", "%Lf", "%zu", "%",
753        ] {
754            assert!(validate_numeric_format::<f32>(format).is_err(), "{format}");
755        }
756
757        assert!(validate_numeric_format::<i32>("%lld").is_err());
758        assert!(validate_numeric_format::<i64>("%d").is_err());
759        assert!(validate_numeric_format::<i32>("%u").is_err());
760        assert!(validate_numeric_format::<u32>("%d").is_err());
761        assert!(validate_numeric_format::<f64>("%d").is_err());
762    }
763
764    #[test]
765    fn directives_must_fit_dear_imguis_sanitization_buffer() {
766        let accepted = format!("%{}f", "0".repeat(28));
767        assert_eq!(accepted.len(), 30);
768        assert_eq!(validate_numeric_format::<f64>(&accepted), Ok(()));
769
770        let rejected = format!("%{}f", "0".repeat(29));
771        assert_eq!(rejected.len(), 31);
772        assert!(matches!(
773            validate_numeric_format::<f64>(&rejected),
774            Err(NumericFormatError::DirectiveTooLong { length: 31, .. })
775        ));
776    }
777
778    #[test]
779    fn width_and_precision_are_parsed_with_bounded_arithmetic() {
780        for format in ["%31d", "%031d"] {
781            assert_eq!(validate_numeric_format::<i32>(format), Ok(()), "{format}");
782        }
783        for format in ["%.99f", "%.099f"] {
784            assert_eq!(validate_numeric_format::<f64>(format), Ok(()), "{format}");
785        }
786
787        assert!(matches!(
788            validate_numeric_format::<i32>("%32d"),
789            Err(NumericFormatError::WidthTooLarge {
790                width: 32,
791                maximum: 31,
792                ..
793            })
794        ));
795        assert!(matches!(
796            validate_numeric_format::<f64>("%.100f"),
797            Err(NumericFormatError::PrecisionTooLarge {
798                precision: 100,
799                maximum: 99,
800                ..
801            })
802        ));
803        assert!(matches!(
804            validate_numeric_format::<i32>("%999999999999999999999999999999d"),
805            Err(NumericFormatError::WidthTooLarge { .. })
806        ));
807    }
808
809    #[test]
810    fn flags_must_have_defined_semantics_for_the_conversion() {
811        for format in ["%#d", "%#i"] {
812            assert!(matches!(
813                validate_numeric_format::<i32>(format),
814                Err(NumericFormatError::IncompatibleFlag { flag: '#', .. })
815            ));
816        }
817        for format in ["%#u", "%+u", "% u", "%+o", "% x", "%+X"] {
818            assert!(
819                matches!(
820                    validate_numeric_format::<u32>(format),
821                    Err(NumericFormatError::IncompatibleFlag { .. })
822                ),
823                "{format}"
824            );
825        }
826    }
827
828    #[test]
829    fn complete_formats_have_a_utf8_byte_limit() {
830        let accepted = format!("{}x", "界".repeat(1365));
831        assert_eq!(accepted.len(), MAX_FORMAT_BYTES);
832        assert_eq!(validate_numeric_format::<f64>(&accepted), Ok(()));
833
834        let rejected = format!("{accepted}x");
835        assert!(matches!(
836            validate_numeric_format::<f64>(&rejected),
837            Err(NumericFormatError::FormatTooLong {
838                byte_offset: MAX_FORMAT_BYTES,
839                length: 4097,
840                maximum: MAX_FORMAT_BYTES,
841            })
842        ));
843
844        let portable_wide = format!("{}%I64d", "x".repeat(4091));
845        assert_eq!(portable_wide.len(), MAX_FORMAT_BYTES);
846        assert!(NumericFormat::<i64>::new(portable_wide).is_ok());
847
848        let target_expansion = format!("{}%lld", "x".repeat(4092));
849        assert_eq!(target_expansion.len(), MAX_FORMAT_BYTES);
850        assert!(matches!(
851            NumericFormat::<i64>::new(target_expansion),
852            Err(NumericFormatError::FormatTooLong {
853                length: 4097,
854                maximum: MAX_FORMAT_BYTES,
855                ..
856            })
857        ));
858    }
859
860    #[test]
861    fn validation_reports_the_first_unsafe_construct() {
862        let error = validate_numeric_format::<f64>("value: %f then %n").unwrap_err();
863        assert_eq!(error.byte_offset(), 15);
864        assert!(matches!(
865            error,
866            NumericFormatError::MultipleConversions { .. }
867        ));
868
869        let error = validate_numeric_format::<f64>("\0%f").unwrap_err();
870        assert_eq!(error.byte_offset(), 0);
871        assert!(matches!(error, NumericFormatError::InteriorNul { .. }));
872    }
873}