Skip to main content

rfc5545_types/
string.rs

1//! String data model types for RFC 5545.
2
3use std::num::NonZero;
4
5use dizzy::DstNewtype;
6
7/// An error indicating that a string is not valid `paramtext`.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
9#[error("invalid character {c:?} at index {index}")]
10pub struct InvalidParamTextError {
11    /// The index of the first invalid character.
12    pub index: usize,
13    /// The invalid character.
14    pub c: char,
15}
16
17/// A `paramtext` value as defined by RFC 5545 §3.1.
18///
19/// ```text
20/// paramtext = *SAFE-CHAR
21/// ```
22///
23/// This is the unquoted form of a property parameter value. The quoted form (`QSAFE-CHAR`) allows
24/// additional characters like `:`, `;`, and `,`.
25#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, DstNewtype)]
26#[dizzy(invariant = ParamText::str_is_paramtext, error = InvalidParamTextError)]
27#[dizzy(constructor = pub new)]
28#[dizzy(getter = pub const as_str)]
29#[dizzy(derive(Debug, CloneBoxed, IntoBoxed))]
30#[dizzy(owned = pub ParamTextBuf(String))]
31#[dizzy(derive_owned(Debug, IntoBoxed))]
32#[repr(transparent)]
33pub struct ParamText(str);
34
35impl ParamText {
36    fn str_is_paramtext(s: &str) -> Result<(), InvalidParamTextError> {
37        for (index, c) in s.chars().enumerate() {
38            if !char_is_safe_char(c) {
39                return Err(InvalidParamTextError { index, c });
40            }
41        }
42        Ok(())
43    }
44}
45
46/// Returns `true` iff `c` is a `SAFE-CHAR` as defined by RFC 5545 §3.1.
47///
48/// ```text
49/// SAFE-CHAR = WSP / %x21 / %x23-2B / %x2D-39 / %x3C-7E / NON-US-ASCII
50/// ```
51///
52/// NB: RFC 5545 doesn't define the `WSP` rule in its grammar, as it is defined by RFC 5234 to be
53/// either the literal space (U+0020) or the horizontal tab (U+0009).
54const fn char_is_safe_char(c: char) -> bool {
55    match c {
56        '\t' | ' ' | '!' | '#'..='+' | '-'..='9' | '<'..='~' => true,
57        _ => !c.is_ascii(),
58    }
59}
60
61// ============================================================================
62// Text / TextBuf
63// ============================================================================
64
65/// An error indicating that a string is not a valid TEXT value.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
67#[error("invalid character {c:?} at byte index {index}")]
68pub struct InvalidTextError {
69    /// The byte index of the first invalid character.
70    pub index: usize,
71    /// The invalid character.
72    pub c: char,
73}
74
75/// A textual value (RFC 5545 §3.3.11).
76///
77/// This is the subset of `str` values that are permissible TEXT property values in iCalendar:
78/// all strings that do not contain ASCII control characters other than HTAB (U+0009) and
79/// LF (U+000A).
80#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, DstNewtype)]
81#[dizzy(invariant = Text::str_is_text, error = InvalidTextError)]
82#[dizzy(constructor = pub new)]
83#[dizzy(getter = pub const as_str)]
84#[dizzy(derive(Debug, CloneBoxed, IntoBoxed))]
85#[dizzy(owned = pub TextBuf(String))]
86#[dizzy(derive_owned(Debug, IntoBoxed))]
87#[repr(transparent)]
88pub struct Text(str);
89
90impl Text {
91    /// Returns `true` iff `c` is valid in a TEXT value.
92    pub const fn char_is_valid(c: char) -> bool {
93        char_is_text(c)
94    }
95
96    pub(crate) fn str_is_text(s: &str) -> Result<(), InvalidTextError> {
97        for (index, c) in s.char_indices() {
98            if !char_is_text(c) {
99                return Err(InvalidTextError { index, c });
100            }
101        }
102        Ok(())
103    }
104}
105
106impl std::fmt::Display for Text {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        f.write_str(self.as_str())
109    }
110}
111
112impl TextBuf {
113    /// Consumes the `TextBuf` and returns the inner `String`.
114    pub fn into_string(self) -> String {
115        self.__dizzy_owned_inner
116    }
117
118    /// Creates a `TextBuf` from a `String`, validating the text invariant.
119    pub fn from_string(s: String) -> Result<Self, InvalidTextError> {
120        Text::str_is_text(&s)?;
121        // SAFETY: validated above; the inner field is correct
122        Ok(Self {
123            __data: std::marker::PhantomData,
124            __dizzy_owned_inner: s,
125        })
126    }
127
128    /// Creates a `TextBuf` from a `String` without checking the text invariant.
129    ///
130    /// # Safety
131    /// The string must not contain ASCII control characters other than HTAB or LF.
132    pub unsafe fn from_string_unchecked(s: String) -> Self {
133        debug_assert!(Text::str_is_text(&s).is_ok());
134        Self {
135            __data: std::marker::PhantomData,
136            __dizzy_owned_inner: s,
137        }
138    }
139}
140
141impl std::fmt::Display for TextBuf {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        f.write_str(self.as_str())
144    }
145}
146
147/// Returns `true` iff `c` is valid in a TEXT value (RFC 5545 §3.3.11).
148///
149/// Allowed: all characters except ASCII control characters, with the exceptions of
150/// HTAB (U+0009) and LF (U+000A) which are permitted.
151const fn char_is_text(c: char) -> bool {
152    if c.is_ascii_control() {
153        // Allow HTAB and LF
154        c == '\t' || c == '\n'
155    } else {
156        true
157    }
158}
159
160// ============================================================================
161// CaselessStr
162// ============================================================================
163
164/// A case-insensitive `str` with `repr(transparent)` layout.
165///
166/// Equality and hashing are performed case-insensitively (ASCII case folding).
167/// This is used for iCalendar property and parameter names (RFC 5545 §3.1).
168#[derive(Debug, Eq)]
169#[repr(transparent)]
170pub struct CaselessStr(str);
171
172impl CaselessStr {
173    /// Wraps a `&str` as a `&CaselessStr`.
174    #[inline(always)]
175    pub fn new(s: &str) -> &Self {
176        // SAFETY: CaselessStr is repr(transparent) over str
177        unsafe { &*(s as *const str as *const CaselessStr) }
178    }
179
180    /// Returns the underlying string slice.
181    #[inline(always)]
182    pub fn as_str(&self) -> &str {
183        &self.0
184    }
185
186    /// Converts a `Box<str>` into a `Box<CaselessStr>`.
187    #[inline(always)]
188    pub fn from_box_str(value: Box<str>) -> Box<CaselessStr> {
189        // SAFETY: CaselessStr is repr(transparent) over str
190        unsafe { Box::from_raw(Box::into_raw(value) as *mut CaselessStr) }
191    }
192
193    /// Converts a `Box<CaselessStr>` into a `Box<str>`.
194    #[inline(always)]
195    pub fn into_box_str(self: Box<Self>) -> Box<str> {
196        // SAFETY: CaselessStr is repr(transparent) over str
197        unsafe { Box::from_raw(Box::into_raw(self) as *mut str) }
198    }
199}
200
201impl Clone for Box<CaselessStr> {
202    fn clone(&self) -> Self {
203        CaselessStr::from_box_str(Box::<str>::from(&self.0))
204    }
205}
206
207impl<'a> From<&'a str> for &'a CaselessStr {
208    fn from(value: &'a str) -> Self {
209        CaselessStr::new(value)
210    }
211}
212
213impl From<&str> for Box<CaselessStr> {
214    fn from(value: &str) -> Self {
215        CaselessStr::from_box_str(Box::<str>::from(value))
216    }
217}
218
219impl From<Box<str>> for Box<CaselessStr> {
220    fn from(value: Box<str>) -> Self {
221        CaselessStr::from_box_str(value)
222    }
223}
224
225impl From<String> for Box<CaselessStr> {
226    fn from(value: String) -> Self {
227        CaselessStr::from_box_str(value.into_boxed_str())
228    }
229}
230
231impl PartialEq for CaselessStr {
232    fn eq(&self, other: &Self) -> bool {
233        self.0.eq_ignore_ascii_case(&other.0)
234    }
235}
236
237impl PartialEq<str> for CaselessStr {
238    fn eq(&self, other: &str) -> bool {
239        self.0.eq_ignore_ascii_case(other)
240    }
241}
242
243impl std::hash::Hash for CaselessStr {
244    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
245        self.0.len().hash(state);
246        for byte in self.0.as_bytes() {
247            byte.to_ascii_lowercase().hash(state);
248        }
249    }
250}
251
252impl std::fmt::Display for CaselessStr {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        f.write_str(&self.0)
255    }
256}
257
258// ============================================================================
259// Name / NameKind
260// ============================================================================
261
262/// An error indicating that a string is not a valid iCalendar name.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
264pub enum InvalidNameError {
265    /// The string was empty.
266    #[error("name must not be empty")]
267    EmptyString,
268    /// A character that is not alphanumeric or hyphen was found.
269    #[error("invalid character {c:?} at byte index {index}")]
270    InvalidChar {
271        /// The byte index of the invalid character.
272        index: usize,
273        /// The invalid character.
274        c: char,
275    },
276}
277
278/// A name in a content line (RFC 5545 §3.1), or any value satisfying the `iana-token` grammar.
279///
280/// The values of this type are non-empty strings whose characters are ASCII alphanumeric or
281/// hyphen (U+002D).
282#[derive(PartialEq, Eq, PartialOrd, Ord, Hash, DstNewtype)]
283#[dizzy(invariant = Name::str_is_name, error = InvalidNameError)]
284#[dizzy(constructor = pub new)]
285#[dizzy(getter = pub const as_str)]
286#[dizzy(derive(Debug, CloneBoxed, IntoBoxed))]
287#[repr(transparent)]
288pub struct Name(str);
289
290impl Name {
291    fn str_is_name(s: &str) -> Result<(), InvalidNameError> {
292        if s.is_empty() {
293            return Err(InvalidNameError::EmptyString);
294        }
295        for (index, c) in s.char_indices() {
296            if !c.is_ascii_alphanumeric() && c != '-' {
297                return Err(InvalidNameError::InvalidChar { index, c });
298            }
299        }
300        Ok(())
301    }
302
303    /// Returns the length of this name.
304    pub fn len(&self) -> NonZero<usize> {
305        // SAFETY: Name invariant guarantees non-empty
306        unsafe { NonZero::new_unchecked(self.0.len()) }
307    }
308
309    /// Returns the kind of this name (IANA or X-).
310    pub fn kind(&self) -> NameKind {
311        NameKind::of(self.as_str()).expect("Name is non-empty")
312    }
313}
314
315impl std::fmt::Display for Name {
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        f.write_str(self.as_str())
318    }
319}
320
321/// The kind of an iCalendar name.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
323pub enum NameKind {
324    /// An IANA-registered name.
325    Iana,
326    /// An experimental (X-) name.
327    X,
328}
329
330impl NameKind {
331    /// Determines the kind of the given string, or `None` if it is empty.
332    pub fn of(s: &str) -> Option<Self> {
333        match s.as_bytes().first_chunk::<2>() {
334            Some(b"x-" | b"X-") => Some(Self::X),
335            _ if !s.is_empty() => Some(Self::Iana),
336            _ => None,
337        }
338    }
339}
340
341/// An error indicating that a string is not a valid iCalendar `paramtext` or TEXT value,
342/// because it contains an invalid character.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub struct InvalidCharError {
345    /// The byte index of the invalid character.
346    pub byte_index: usize,
347    /// The invalid character.
348    pub invalid_char: char,
349}
350
351impl InvalidCharError {
352    /// Constructs an `InvalidCharError` from a `(byte_index, char)` pair
353    /// as returned by `str::char_indices`.
354    pub const fn from_char_index((byte_index, invalid_char): (usize, char)) -> Self {
355        Self {
356            byte_index,
357            invalid_char,
358        }
359    }
360}