Skip to main content

small_fixed_array/
string.rs

1use alloc::{
2    borrow::{Cow, ToOwned},
3    boxed::Box,
4    string::String,
5    sync::Arc,
6};
7use core::{borrow::Borrow, hash::Hash, str::FromStr};
8
9use crate::{
10    array::FixedArray,
11    inline::InlineString,
12    length::{InvalidStrLength, SmallLen, ValidLength},
13    r#static::StaticStr,
14};
15
16#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
17enum FixedStringRepr<LenT: ValidLength> {
18    Static(StaticStr<LenT>),
19    Heap(FixedArray<u8, LenT>),
20    Inline(InlineString<LenT::InlineStrRepr>),
21}
22
23#[cold]
24fn truncate_string(err: InvalidStrLength, max_len: usize) -> String {
25    let mut value = String::from(err.get_inner());
26    value.truncate(truncate_str(&value, max_len).len());
27    value
28}
29
30#[cold]
31fn truncate_str(string: &str, max_len: usize) -> &str {
32    for len in (0..=max_len).rev() {
33        if string.is_char_boundary(len) {
34            return &string[..len];
35        }
36    }
37
38    unreachable!("Len 0 is a char boundary");
39}
40
41/// A fixed size String with length provided at creation denoted in [`ValidLength`], by default [`u32`].
42///
43/// See module level documentation for more information.
44#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))]
45pub struct FixedString<LenT: ValidLength = SmallLen>(FixedStringRepr<LenT>);
46
47impl<LenT: ValidLength> FixedString<LenT> {
48    #[must_use]
49    pub fn new() -> Self {
50        Self::from_static_trunc("")
51    }
52
53    pub(crate) fn new_inline(val: &str) -> Option<Self> {
54        InlineString::from_str(val)
55            .map(FixedStringRepr::Inline)
56            .map(Self)
57    }
58
59    /// Converts a `&'static str` into a [`FixedString`].
60    ///
61    /// This method will not allocate, or copy the string data.
62    ///
63    /// See [`Self::from_string_trunc`] for truncation behaviour.
64    pub fn from_static_trunc(mut val: &'static str) -> Self {
65        let max_len = LenT::MAX.to_usize();
66        if val.len() > max_len {
67            val = truncate_str(val, max_len);
68        }
69
70        Self(FixedStringRepr::Static(StaticStr::from_static_str(val)))
71    }
72
73    /// Converts a `&str` into a [`FixedString`], allocating if the value cannot fit "inline".
74    ///
75    /// This method will be more efficent if you would otherwise clone a [`String`] to convert into [`FixedString`],
76    /// but should not be used in the case that [`String`] ownership could be transfered without reallocation.
77    ///
78    /// If the `&str` is `'static`, it is preferred to use [`Self::from_static_trunc`], which does not need to copy the data around.
79    ///
80    /// "Inline" refers to Small String Optimisation which allows for Strings with less than 9 to 11 characters
81    /// to be stored without allocation, saving a pointer size and an allocation.
82    ///
83    /// See [`Self::from_string_trunc`] for truncation behaviour.
84    #[must_use]
85    pub fn from_str_trunc(val: &str) -> Self {
86        if let Some(inline) = Self::new_inline(val) {
87            inline
88        } else {
89            Self::from_string_trunc(val.to_owned())
90        }
91    }
92
93    /// Converts a [`String`] into a [`FixedString`], **truncating** if the value is larger than `LenT`'s maximum.
94    ///
95    /// This allows for infallible conversion, but may be lossy in the case of a value above `LenT`'s max.
96    /// For lossless fallible conversion, convert to [`Box<str>`] using [`String::into_boxed_str`] and use [`TryFrom`].
97    #[must_use]
98    pub fn from_string_trunc(str: String) -> Self {
99        match str.try_into() {
100            Ok(val) => val,
101            Err(err) => Self::from_string_trunc(truncate_string(err, LenT::MAX.to_usize())),
102        }
103    }
104
105    /// Returns the length of the [`FixedString`].
106    #[must_use]
107    pub fn len(&self) -> LenT {
108        match &self.0 {
109            FixedStringRepr::Heap(a) => a.len(),
110            FixedStringRepr::Static(a) => a.len(),
111            FixedStringRepr::Inline(a) => a.len().into(),
112        }
113    }
114
115    /// Returns if the length is equal to 0.
116    #[must_use]
117    pub fn is_empty(&self) -> bool {
118        self.len() == LenT::ZERO
119    }
120
121    /// Converts `&`[`FixedString`] to `&str`, this conversion can be performed by [`core::ops::Deref`].
122    #[must_use]
123    pub fn as_str(&self) -> &str {
124        self
125    }
126
127    /// Converts [`FixedString`] to [`String`], this operation should be cheap.
128    #[must_use]
129    pub fn into_string(self) -> String {
130        self.into()
131    }
132
133    #[cfg(test)]
134    #[must_use]
135    pub(crate) fn is_inline(&self) -> bool {
136        matches!(self, Self(FixedStringRepr::Inline(_)))
137    }
138
139    #[cfg(test)]
140    #[must_use]
141    pub(crate) fn is_static(&self) -> bool {
142        matches!(self, Self(FixedStringRepr::Static(_)))
143    }
144}
145
146impl<LenT: ValidLength> core::ops::Deref for FixedString<LenT> {
147    type Target = str;
148
149    fn deref(&self) -> &Self::Target {
150        match &self.0 {
151            // SAFETY: Self holds the type invariant that the array is UTF-8.
152            FixedStringRepr::Heap(a) => unsafe { core::str::from_utf8_unchecked(a) },
153            FixedStringRepr::Static(a) => a.as_str(),
154            FixedStringRepr::Inline(a) => a.as_str(),
155        }
156    }
157}
158
159impl<LenT: ValidLength> Default for FixedString<LenT> {
160    fn default() -> Self {
161        FixedString::new()
162    }
163}
164
165impl<LenT: ValidLength> Clone for FixedString<LenT> {
166    fn clone(&self) -> Self {
167        match &self.0 {
168            FixedStringRepr::Heap(a) => Self(FixedStringRepr::Heap(a.clone())),
169            FixedStringRepr::Inline(a) => Self(FixedStringRepr::Inline(*a)),
170            FixedStringRepr::Static(a) => Self(FixedStringRepr::Static(*a)),
171        }
172    }
173
174    fn clone_from(&mut self, source: &Self) {
175        match (&mut self.0, &source.0) {
176            (FixedStringRepr::Heap(new), FixedStringRepr::Heap(src)) => new.clone_from(src),
177            #[allow(clippy::assigning_clones)]
178            _ => *self = source.clone(),
179        }
180    }
181}
182
183impl<LenT: ValidLength> Hash for FixedString<LenT> {
184    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
185        self.as_str().hash(state);
186    }
187}
188
189impl<LenT: ValidLength> PartialEq for FixedString<LenT> {
190    fn eq(&self, other: &Self) -> bool {
191        self.as_str() == other.as_str()
192    }
193}
194
195impl<LenT: ValidLength> Eq for FixedString<LenT> {}
196
197impl<LenT: ValidLength> PartialEq<String> for FixedString<LenT> {
198    fn eq(&self, other: &String) -> bool {
199        self.as_str().eq(other)
200    }
201}
202
203impl<LenT: ValidLength> PartialEq<&str> for FixedString<LenT> {
204    fn eq(&self, other: &&str) -> bool {
205        self.as_str().eq(*other)
206    }
207}
208
209impl<LenT: ValidLength> PartialEq<str> for FixedString<LenT> {
210    fn eq(&self, other: &str) -> bool {
211        self.as_str().eq(other)
212    }
213}
214
215impl<LenT: ValidLength> PartialEq<FixedString<LenT>> for &str {
216    fn eq(&self, other: &FixedString<LenT>) -> bool {
217        other == self
218    }
219}
220
221impl<LenT: ValidLength> PartialEq<FixedString<LenT>> for str {
222    fn eq(&self, other: &FixedString<LenT>) -> bool {
223        other == self
224    }
225}
226
227impl<LenT: ValidLength> PartialEq<FixedString<LenT>> for String {
228    fn eq(&self, other: &FixedString<LenT>) -> bool {
229        other == self
230    }
231}
232
233impl<LenT: ValidLength> core::cmp::PartialOrd for FixedString<LenT> {
234    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
235        Some(self.cmp(other))
236    }
237}
238
239impl<LenT: ValidLength> core::cmp::Ord for FixedString<LenT> {
240    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
241        self.as_str().cmp(other.as_str())
242    }
243}
244
245impl<LenT: ValidLength> core::fmt::Display for FixedString<LenT> {
246    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
247        f.write_str(self)
248    }
249}
250
251impl<LenT: ValidLength> core::fmt::Debug for FixedString<LenT> {
252    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
253        write!(f, "{:?}", self.as_str())
254    }
255}
256
257impl<LenT: ValidLength> FromStr for FixedString<LenT> {
258    type Err = InvalidStrLength;
259
260    fn from_str(val: &str) -> Result<Self, Self::Err> {
261        if let Some(inline) = Self::new_inline(val) {
262            Ok(inline)
263        } else {
264            Self::try_from(Box::from(val))
265        }
266    }
267}
268
269impl<LenT: ValidLength> TryFrom<Box<str>> for FixedString<LenT> {
270    type Error = InvalidStrLength;
271
272    fn try_from(value: Box<str>) -> Result<Self, Self::Error> {
273        if let Some(inline) = Self::new_inline(&value) {
274            return Ok(inline);
275        }
276
277        match value.into_boxed_bytes().try_into() {
278            Ok(val) => Ok(Self(FixedStringRepr::Heap(val))),
279            Err(err) => Err(err
280                .try_into()
281                .expect("Box<str> -> Box<[u8]> should stay valid UTF8")),
282        }
283    }
284}
285
286impl<LenT: ValidLength> TryFrom<String> for FixedString<LenT> {
287    type Error = InvalidStrLength;
288
289    fn try_from(value: String) -> Result<Self, Self::Error> {
290        if let Some(inline) = Self::new_inline(&value) {
291            return Ok(inline);
292        }
293
294        value.into_boxed_str().try_into()
295    }
296}
297
298impl<LenT: ValidLength> From<char> for FixedString<LenT> {
299    fn from(value: char) -> Self {
300        use alloc::vec;
301
302        if let Some(value) = InlineString::from_char(value) {
303            return Self(FixedStringRepr::Inline(value));
304        }
305
306        let mut bytes = vec![0; value.len_utf8()].into_boxed_slice();
307
308        value.encode_utf8(&mut bytes);
309
310        let bytes = bytes
311            .try_into()
312            .expect("len_utf8 is at most 4, so it will fit in u8");
313
314        Self(FixedStringRepr::Heap(bytes))
315    }
316}
317
318impl<LenT: ValidLength> From<FixedString<LenT>> for String {
319    fn from(value: FixedString<LenT>) -> Self {
320        Box::<str>::from(value).into()
321    }
322}
323
324impl<LenT: ValidLength> From<FixedString<LenT>> for Box<str> {
325    fn from(value: FixedString<LenT>) -> Self {
326        match value.0 {
327            FixedStringRepr::Inline(a) => a.as_str().into(),
328            FixedStringRepr::Static(a) => a.as_str().into(),
329            // SAFETY: Self holds the type invariant that the array is UTF-8.
330            FixedStringRepr::Heap(a) => unsafe { alloc::str::from_boxed_utf8_unchecked(a.into()) },
331        }
332    }
333}
334
335impl<'a, LenT: ValidLength> From<&'a FixedString<LenT>> for Cow<'a, str> {
336    fn from(value: &'a FixedString<LenT>) -> Self {
337        Cow::Borrowed(value.as_str())
338    }
339}
340
341impl<LenT: ValidLength> From<FixedString<LenT>> for Cow<'_, str> {
342    fn from(value: FixedString<LenT>) -> Self {
343        match value.0 {
344            FixedStringRepr::Static(static_str) => Cow::Borrowed(static_str.as_str()),
345            _ => Cow::Owned(value.into()),
346        }
347    }
348}
349
350impl<LenT: ValidLength> AsRef<str> for FixedString<LenT> {
351    fn as_ref(&self) -> &str {
352        self
353    }
354}
355
356impl<LenT: ValidLength> Borrow<str> for FixedString<LenT> {
357    fn borrow(&self) -> &str {
358        self
359    }
360}
361
362#[cfg(feature = "std")]
363impl<LenT: ValidLength> AsRef<std::path::Path> for FixedString<LenT> {
364    fn as_ref(&self) -> &std::path::Path {
365        self.as_str().as_ref()
366    }
367}
368
369#[cfg(feature = "std")]
370impl<LenT: ValidLength> AsRef<std::ffi::OsStr> for FixedString<LenT> {
371    fn as_ref(&self) -> &std::ffi::OsStr {
372        self.as_str().as_ref()
373    }
374}
375
376impl<LenT: ValidLength> From<FixedString<LenT>> for Arc<str> {
377    fn from(value: FixedString<LenT>) -> Self {
378        Arc::from(value.into_string())
379    }
380}
381
382#[cfg(feature = "to-arraystring")]
383impl to_arraystring::ToArrayString for &FixedString<u8> {
384    const MAX_LENGTH: usize = 255;
385    type ArrayString = to_arraystring::ArrayString<255>;
386
387    fn to_arraystring(self) -> Self::ArrayString {
388        Self::ArrayString::from(self).unwrap()
389    }
390}
391
392#[cfg(feature = "serde")]
393impl<'de, LenT: ValidLength> serde::Deserialize<'de> for FixedString<LenT> {
394    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
395        use core::marker::PhantomData;
396
397        struct Visitor<LenT: ValidLength>(PhantomData<LenT>);
398
399        impl<LenT: ValidLength> serde::de::Visitor<'_> for Visitor<LenT> {
400            type Value = FixedString<LenT>;
401
402            fn expecting(&self, formatter: &mut core::fmt::Formatter) -> core::fmt::Result {
403                write!(formatter, "a string up to {} bytes long", LenT::MAX)
404            }
405
406            fn visit_str<E: serde::de::Error>(self, val: &str) -> Result<Self::Value, E> {
407                FixedString::from_str(val).map_err(E::custom)
408            }
409
410            fn visit_string<E: serde::de::Error>(self, val: String) -> Result<Self::Value, E> {
411                FixedString::try_from(val.into_boxed_str()).map_err(E::custom)
412            }
413        }
414
415        deserializer.deserialize_string(Visitor(PhantomData))
416    }
417}
418
419#[cfg(feature = "serde")]
420impl<LenT: ValidLength> serde::Serialize for FixedString<LenT> {
421    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
422        self.as_str().serialize(serializer)
423    }
424}
425
426#[cfg(test)]
427mod test {
428    use super::*;
429
430    fn check_u8_roundtrip_generic(to_fixed: fn(String) -> FixedString<u8>) {
431        for i in 0..=u8::MAX {
432            let original = "a".repeat(i.into());
433            let fixed = to_fixed(original);
434
435            assert!(fixed.bytes().all(|c| c == b'a'));
436            assert_eq!(fixed.len(), i);
437
438            if !fixed.is_static() {
439                assert_eq!(fixed.is_inline(), fixed.len() <= 9);
440            }
441        }
442    }
443
444    // primarily intended to ensure no hangs occur
445    #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
446    fn check_u32_partial_roundtrip_generic(to_fixed: fn(String) -> FixedString<u32>) {
447        for i in 0..=400u32 {
448            let original = "a".repeat(i.try_into().expect("should be less than usize::MAX"));
449            let fixed = to_fixed(original);
450
451            assert!(fixed.bytes().all(|c| c == b'a'));
452            assert_eq!(fixed.len(), i);
453
454            if !fixed.is_static() {
455                assert_eq!(fixed.is_inline(), fixed.len() <= 12);
456            }
457        }
458    }
459
460    fn check_default_generic<LenT: ValidLength>() {
461        let fixed = FixedString::<LenT>::default();
462
463        assert!(fixed.is_static());
464        assert_eq!(fixed.as_str(), "");
465    }
466
467    #[test]
468    fn test_truncating_behaviour() {
469        const STR: &str = "______________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________________🦀";
470
471        let string = FixedString::<u8>::from_static_trunc(STR);
472
473        let str = std::str::from_utf8(string.as_bytes()).expect("is utf8");
474
475        assert_eq!(str, string.as_str());
476        assert_ne!(STR, str);
477    }
478
479    #[test]
480    fn test_from_static_to_cow() {
481        const STR: &str = "static string";
482
483        let string = FixedString::<u8>::from_static_trunc(STR);
484
485        let cow: std::borrow::Cow<'static, _> = string.into();
486
487        assert_eq!(cow, STR);
488
489        let std::borrow::Cow::Borrowed(string) = cow else {
490            panic!("Expected borrowed string");
491        };
492
493        assert_eq!(string, STR);
494    }
495
496    #[test]
497    fn check_u8_roundtrip() {
498        check_u8_roundtrip_generic(|original| {
499            FixedString::<u8>::try_from(original.into_boxed_str()).unwrap()
500        });
501    }
502
503    #[test]
504    fn check_u8_roundtrip_static() {
505        check_u8_roundtrip_generic(|original| {
506            let static_str = Box::leak(original.into_boxed_str());
507            FixedString::from_static_trunc(static_str)
508        });
509    }
510
511    #[test]
512    #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
513    fn check_u32_partial_roundtrip() {
514        check_u32_partial_roundtrip_generic(|original| {
515            FixedString::<u32>::try_from(original).unwrap()
516        });
517    }
518
519    #[test]
520    #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
521    fn check_u32_partial_roundtrip_static() {
522        check_u32_partial_roundtrip_generic(|original| {
523            let static_str = Box::leak(original.into_boxed_str());
524            FixedString::from_static_trunc(static_str)
525        });
526    }
527
528    #[test]
529    #[cfg(feature = "serde")]
530    fn check_u8_roundtrip_serde() {
531        check_u8_roundtrip_generic(|original| {
532            serde_json::from_str(&alloc::format!("\"{original}\"")).unwrap()
533        });
534    }
535
536    #[test]
537    #[cfg(feature = "to-arraystring")]
538    fn check_u8_roundtrip_arraystring() {
539        use to_arraystring::ToArrayString;
540
541        check_u8_roundtrip_generic(|original| {
542            FixedString::from_str_trunc(
543                FixedString::from_string_trunc(original)
544                    .to_arraystring()
545                    .as_str(),
546            )
547        });
548    }
549
550    #[test]
551    fn check_default_u8() {
552        check_default_generic::<u8>();
553    }
554
555    #[test]
556    fn check_default_u16() {
557        check_default_generic::<u16>();
558    }
559
560    #[test]
561    #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
562    fn check_default_u32() {
563        check_default_generic::<u32>();
564    }
565
566    #[test]
567    fn check_sizes() {
568        type DoubleOpt<T> = Option<Option<T>>;
569
570        assert_eq!(core::mem::size_of::<Option<InlineString<[u8; 11]>>>(), 12);
571        assert_eq!(core::mem::align_of::<Option<InlineString<[u8; 11]>>>(), 1);
572        assert_eq!(core::mem::size_of::<Option<FixedArray<u8, u32>>>(), 12);
573        // https://github.com/rust-lang/rust/issues/119507
574        assert_eq!(core::mem::size_of::<DoubleOpt<FixedArray<u8, u32>>>(), 13);
575        assert_eq!(core::mem::align_of::<Option<FixedArray<u8, u32>>>(), 1);
576        // This sucks!! I want to fix this, soon.... this should so niche somehow.
577        assert_eq!(core::mem::size_of::<FixedStringRepr<u32>>(), 13);
578        assert_eq!(core::mem::align_of::<FixedStringRepr<u32>>(), 1);
579    }
580
581    #[test]
582    fn from_char_u8() {
583        let s: FixedString<u8> = 'a'.into();
584        assert_eq!(s.len(), 1);
585        assert!(s.is_inline());
586
587        let s: FixedString<u8> = '¼'.into();
588        assert_eq!(s.len(), 2);
589        assert!(s.is_inline());
590
591        let s: FixedString<u8> = 'âš¡'.into();
592        assert_eq!(s.len(), 3);
593        assert!(s.is_inline());
594
595        let s: FixedString<u8> = '🦀'.into();
596        assert_eq!(s.len(), 4);
597        #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
598        assert!(s.is_inline());
599    }
600
601    #[test]
602    fn from_char_u16() {
603        let s: FixedString<u16> = 'a'.into();
604        assert_eq!(s.len(), 1);
605        assert!(s.is_inline());
606
607        let s: FixedString<u16> = '¼'.into();
608        assert_eq!(s.len(), 2);
609        assert!(s.is_inline());
610
611        let s: FixedString<u16> = 'âš¡'.into();
612        assert_eq!(s.len(), 3);
613        assert!(s.is_inline());
614
615        let s: FixedString<u16> = '🦀'.into();
616        assert_eq!(s.len(), 4);
617        assert!(s.is_inline());
618    }
619
620    #[test]
621    #[cfg(any(target_pointer_width = "64", target_pointer_width = "32"))]
622    fn from_char_u32() {
623        let s: FixedString<u32> = 'a'.into();
624        assert_eq!(s.len(), 1);
625        assert!(s.is_inline());
626
627        let s: FixedString<u32> = '¼'.into();
628        assert_eq!(s.len(), 2);
629        assert!(s.is_inline());
630
631        let s: FixedString<u32> = 'âš¡'.into();
632        assert_eq!(s.len(), 3);
633        assert!(s.is_inline());
634
635        let s: FixedString<u32> = '🦀'.into();
636        assert_eq!(s.len(), 4);
637        assert!(s.is_inline());
638    }
639}