vparser/
lib.rs

1// Copyright 2023-2024 Hugo Osvaldo Barrera
2//
3// SPDX-License-Identifier: ISC
4
5//! See [`Parser`] as the main entry point to this library.
6#![no_std]
7#![deny(clippy::pedantic)]
8
9extern crate alloc;
10
11use core::{
12    iter::{Enumerate, FusedIterator, Peekable},
13    str::Bytes,
14};
15
16use alloc::{
17    borrow::{Cow, ToOwned},
18    string::ToString,
19};
20
21/// A valid content line.
22///
23/// Continuation lines may be folded; wrapped with continuation lines separated by a CRLF
24/// immediately followed by a single linear white-space character (i.e., SPACE or HTAB).
25#[derive(Debug, PartialEq, Clone)]
26pub struct ContentLine<'input> {
27    // TODO: use indeces instead; they're half the size and slightly simpler.
28    /// The entire raw line, unaltered.
29    raw: &'input str,
30    /// Everything before the first colon or semicolon.
31    name: &'input str,
32    /// Everything before the first colon and after the first semicolon.
33    params: &'input str,
34    /// Everything after the first unquoted colon.
35    value: &'input str,
36}
37
38impl<'input> ContentLine<'input> {
39    /// Return the raw line without any unfolding.
40    #[must_use]
41    pub fn raw(&self) -> &'input str {
42        self.raw
43    }
44
45    /// Return this line's name, with continuation lines unfolded.
46    #[must_use]
47    pub fn name(&self) -> Cow<'input, str> {
48        unfold_lines(self.name)
49    }
50
51    /// Return this line's parameter(s), with continuation lines unfolded.
52    #[must_use]
53    pub fn params(&self) -> Cow<'input, str> {
54        unfold_lines(self.params)
55    }
56
57    /// Return this line's value, with continuation lines unfolded.
58    #[must_use]
59    pub fn value(&self) -> Cow<'input, str> {
60        unfold_lines(self.value)
61    }
62
63    /// Return the entire line unfolded.
64    ///
65    /// Note that the line may exceed the maximum length, making it technically invalid. This is
66    /// however, suitable for comparing equality between to lines.
67    #[must_use]
68    pub fn unfolded(&self) -> Cow<'input, str> {
69        unfold_lines(self.raw)
70    }
71
72    /// Return this content line with normalised folds
73    ///
74    /// The returned line shall be semantically equivalent to the input line. It will be folded
75    /// making each logical line as long as possible (e.g.: no more than 75 octets, excluding the
76    /// line jump).
77    #[must_use]
78    // INVARIANT: content lines only have valid continuation lines.
79    #[allow(clippy::missing_panics_doc)]
80    pub fn normalise_folds(&self) -> Cow<'input, str> {
81        let mut result = Cow::Borrowed(self.raw);
82        // Index where current portion begins.
83        let mut cur = 0;
84        // Not strictly where the line started; this is also shifted to compensate for unfolds.
85        let mut line_start = 0;
86
87        let mut chars = self.raw.char_indices().peekable();
88        while let Some((i, c)) = chars.next() {
89            if c == '\r' && matches!(chars.peek(), Some((_, '\n'))) {
90                chars.next(); // Advance the peeked LF.
91
92                assert!(
93                    matches!(chars.next(), Some((_, ' ' | '\t'))),
94                    "continuation line must start with a space or tab",
95                );
96
97                let next = chars.peek().map_or(1, |(_, c)| c.len_utf8());
98                if i - line_start + next >= 75 {
99                    // In this case we don't need to mutate anything; the whole line and CRLF
100                    // stay they same.
101
102                    // Note that the space already counts towards the following line.
103                    line_start = i + 2;
104                } else {
105                    let portion = &self.raw[cur..i];
106                    match result {
107                        Cow::Borrowed(_) => {
108                            result = Cow::Owned(portion.to_owned());
109                        }
110                        Cow::Owned(ref mut s) => {
111                            s.push_str(portion);
112                        }
113                    }
114                    // Shift by three to avoid counting CRLF<SPACE>.
115                    line_start += 3;
116                    cur = i + 3;
117                }
118            } else if (i - line_start) + c.len_utf8() >= 75 {
119                let portion = &self.raw[cur..i];
120                match result {
121                    Cow::Borrowed(_) => {
122                        result = Cow::Owned(portion.to_string() + "\r\n " + &c.to_string());
123                    }
124                    Cow::Owned(ref mut s) => {
125                        s.push_str(portion);
126                        s.push_str("\r\n ");
127                        s.push(c);
128                    }
129                }
130                cur = i + c.len_utf8();
131                line_start = i - 1; // -1 to account for the inserted space.
132            }
133        }
134
135        if let Cow::Owned(ref mut s) = result {
136            let portion = &self.raw[cur..];
137            s.push_str(portion);
138        }
139
140        result
141    }
142}
143
144/// A flexible parser for icalendar/vcard.
145///
146/// This parser is designed to gracefully handle bogus data. The semantics of names, parameters,
147/// and values are not validated. For example, a "date" may be an arbitrary string. Users of this
148/// library should validate any data with the usual considerations for handling input data.
149///
150/// This parser itself is zero-copy. Accessors methods return unfolded names, values and
151/// parameters. These accessors return a [`Cow`], and will allocate in case of folded lines.
152///
153/// It should be used via its [`Iterator`] implementation which iterates over [`ContentLine`]
154/// instances.
155///
156/// # Known issues
157///
158/// - A trailing empty line is lost.
159///
160/// # See also
161///
162/// - [`Parser::next`]
163pub struct Parser<'data> {
164    data: &'data str,
165    characters: Peekable<Enumerate<Bytes<'data>>>,
166}
167
168impl<'data> Parser<'data> {
169    /// Create a new parser with the given input data.
170    ///
171    /// The input data MAY have unfolded continuation lines.
172    #[must_use]
173    pub fn new(data: &'data str) -> Parser<'data> {
174        Parser {
175            data,
176            characters: data.bytes().enumerate().peekable(),
177        }
178    }
179
180    /// Returns the unparsed portion of the input data.
181    ///
182    /// Does not affect advance the position of this iterator.
183    #[must_use]
184    pub fn remainder(&mut self) -> &str {
185        &self.data[self
186            .characters
187            .peek()
188            .map_or_else(|| self.data.len(), |(i, _)| *i)..]
189    }
190}
191
192impl<'data> Iterator for Parser<'data> {
193    type Item = ContentLine<'data>;
194
195    /// Returns the next content line from the input data.
196    ///
197    /// This method is no-copy; a [`ContentLine`] contains only pointers to the original input
198    /// data.
199    ///
200    /// Returns `None` after the last line has been returned. Returns `None` if called after the
201    /// iterator has been exhausted.
202    #[allow(clippy::too_many_lines)]
203    fn next(&mut self) -> Option<ContentLine<'data>> {
204        let (start, _) = *self.characters.peek()?;
205        loop {
206            match self.characters.next() {
207                Some((semicolon, b';')) => loop {
208                    match self.characters.next() {
209                        Some((colon, b':')) => loop {
210                            match self.characters.next() {
211                                Some((cr, b'\r')) => {
212                                    if !self.characters.peek().is_some_and(|(_, c)| *c == b'\n') {
213                                        continue; // Not CRLF.
214                                    };
215                                    self.characters.next(); // Advance the peeked LF.
216                                    if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
217                                        continue; // Continuation line
218                                    }
219                                    return Some(ContentLine {
220                                        raw: &self.data[start..cr],
221                                        name: &self.data[start..semicolon],
222                                        params: &self.data[semicolon + 1..colon],
223                                        value: &self.data[colon + 1..cr],
224                                    });
225                                }
226                                Some((_, _)) => {}
227                                None => {
228                                    return Some(ContentLine {
229                                        raw: &self.data[start..],
230                                        name: &self.data[start..semicolon],
231                                        params: &self.data[semicolon + 1..colon],
232                                        value: &self.data[colon + 1..],
233                                    })
234                                }
235                            }
236                        },
237                        Some((_, b'"')) => loop {
238                            match self.characters.next() {
239                                Some((_, b'"')) => break,
240                                Some((_, _)) => {}
241                                None => {
242                                    // WARN: reached EOF, expected closing quote
243                                    return Some(ContentLine {
244                                        raw: &self.data[start..],
245                                        name: &self.data[start..semicolon],
246                                        params: &self.data[semicolon + 1..],
247                                        value: &self.data[semicolon..semicolon],
248                                    });
249                                }
250                            }
251                        },
252                        Some((cr, b'\r')) => {
253                            if !self.characters.peek().is_some_and(|(_, c)| *c == b'\n') {
254                                continue; // Not CRLF.
255                            };
256                            self.characters.next(); // Advance the peeked LF.
257                            if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
258                                continue; // Continuation line
259                            }
260                            return Some(ContentLine {
261                                raw: &self.data[start..cr],
262                                name: &self.data[start..semicolon],
263                                params: &self.data[semicolon + 1..cr],
264                                value: &self.data[semicolon..semicolon],
265                            });
266                        }
267                        Some((_, _)) => {}
268                        None => {
269                            return Some(ContentLine {
270                                raw: &self.data[start..],
271                                name: &self.data[start..semicolon],
272                                params: &self.data[semicolon + 1..],
273                                value: &self.data[semicolon..semicolon],
274                            });
275                        }
276                    };
277                },
278                // Begin value
279                Some((colon, b':')) => loop {
280                    match self.characters.next() {
281                        Some((cr, b'\r')) => {
282                            if !self.characters.peek().is_some_and(|(_, c)| *c == b'\n') {
283                                continue; // Not CRLF.
284                            };
285                            self.characters.next(); // Advance the peeked LF.
286                            if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
287                                continue; // Continuation line
288                            }
289                            return Some(ContentLine {
290                                raw: &self.data[start..cr],
291                                name: &self.data[start..colon],
292                                params: &self.data[colon..colon],
293                                value: &self.data[colon + 1..cr],
294                            });
295                        }
296                        Some((_, _)) => {}
297                        None => {
298                            return Some(ContentLine {
299                                raw: &self.data[start..],
300                                name: &self.data[start..colon],
301                                params: &self.data[colon..colon],
302                                value: &self.data[colon + 1..],
303                            });
304                        }
305                    }
306                },
307                Some((cr, b'\r')) => {
308                    if !self.characters.peek().is_some_and(|(_, c)| *c == b'\n') {
309                        continue; // Not CRLF.
310                    };
311                    self.characters.next(); // Advance the peeked LF.
312                    if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
313                        continue; // Continuation line
314                    }
315                    return Some(ContentLine {
316                        raw: &self.data[start..cr],
317                        name: &self.data[start..cr],
318                        params: &self.data[start..start],
319                        value: &self.data[start..start],
320                    });
321                }
322                Some((_, _)) => {}
323                None => {
324                    return Some(ContentLine {
325                        raw: &self.data[start..],
326                        name: &self.data[start..],
327                        params: &self.data[start..start],
328                        value: &self.data[start..start],
329                    });
330                }
331            }
332        }
333    }
334}
335
336impl<'data> FusedIterator for Parser<'data> {}
337
338/// Unfold multiple continuation lines into a single line.
339///
340/// # Panics
341///
342/// If the input string has multiple non-continuation lines.
343fn unfold_lines(lines: &str) -> Cow<str> {
344    let mut result = Cow::Borrowed(lines);
345    let mut cur = 0;
346
347    let mut chars = lines.char_indices().peekable();
348    while let Some((i, c)) = chars.next() {
349        if c != '\r' {
350            continue;
351        }
352        if !chars.peek().is_some_and(|(_, c)| *c == '\n') {
353            continue; // Not CRLF.
354        };
355        chars.next(); // Advance the peeked LF.
356
357        assert!(
358            matches!(chars.next(), Some((_, ' ' | '\t'))),
359            "continuation line is not a continuation line",
360        );
361
362        let portion = &lines[cur..i];
363        match result {
364            Cow::Borrowed(_) => {
365                result = Cow::Owned(portion.to_owned());
366            }
367            Cow::Owned(ref mut s) => {
368                s.push_str(portion);
369            }
370        }
371        cur = i + 3;
372    }
373
374    if let Cow::Owned(ref mut s) = result {
375        let portion = &lines[cur..];
376        s.push_str(portion);
377    }
378
379    result
380}
381
382#[cfg(test)]
383#[allow(clippy::too_many_lines)]
384mod test {
385    use crate::{unfold_lines, ContentLine, Parser};
386    use alloc::borrow::Cow;
387
388    #[test]
389    fn test_complete_example() {
390        let data = [
391            "BEGIN:VCALENDAR",
392            "VERSION:2.0",
393            "PRODID:nl.whynothugo.todoman",
394            "BEGIN:VTODO",
395            "DTSTAMP:20231126T095923Z",
396            "DUE;TZID=Asia/Shanghai:20231128T090000",
397            "SUMMARY:dummy todo for parser tests",
398            "UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
399            "END:VTODO",
400            "END:VCALENDAR",
401            // Note: this calendar is not entirely semantically valid;
402            // it is missing the timezone which is referred to in DUE.
403        ]
404        .join("\r\n");
405
406        let mut parser = Parser::new(&data);
407        assert_eq!(
408            parser.next(),
409            Some(ContentLine {
410                raw: "BEGIN:VCALENDAR",
411                name: "BEGIN",
412                params: "",
413                value: "VCALENDAR"
414            })
415        );
416        assert_eq!(
417            parser.next(),
418            Some(ContentLine {
419                raw: "VERSION:2.0",
420                name: "VERSION",
421                params: "",
422                value: "2.0",
423            })
424        );
425        assert_eq!(
426            parser.next(),
427            Some(ContentLine {
428                raw: "PRODID:nl.whynothugo.todoman",
429                name: "PRODID",
430                params: "",
431                value: "nl.whynothugo.todoman",
432            })
433        );
434        assert_eq!(
435            parser.next(),
436            Some(ContentLine {
437                raw: "BEGIN:VTODO",
438                name: "BEGIN",
439                params: "",
440                value: "VTODO",
441            })
442        );
443        assert_eq!(
444            parser.next(),
445            Some(ContentLine {
446                raw: "DTSTAMP:20231126T095923Z",
447                name: "DTSTAMP",
448                params: "",
449                value: "20231126T095923Z",
450            })
451        );
452        assert_eq!(
453            parser.next(),
454            Some(ContentLine {
455                raw: "DUE;TZID=Asia/Shanghai:20231128T090000",
456                name: "DUE",
457                params: "TZID=Asia/Shanghai",
458                value: "20231128T090000",
459            })
460        );
461        assert_eq!(
462            parser.next(),
463            Some(ContentLine {
464                raw: "SUMMARY:dummy todo for parser tests",
465                name: "SUMMARY",
466                params: "",
467                value: "dummy todo for parser tests",
468            })
469        );
470        assert_eq!(
471            parser.next(),
472            Some(ContentLine {
473                raw: "UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
474                name: "UID",
475                params: "",
476                value: "565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
477            })
478        );
479        assert_eq!(
480            parser.next(),
481            Some(ContentLine {
482                raw: "END:VTODO",
483                name: "END",
484                params: "",
485                value: "VTODO",
486            })
487        );
488        assert_eq!(
489            parser.next(),
490            Some(ContentLine {
491                raw: "END:VCALENDAR",
492                name: "END",
493                params: "",
494                value: "VCALENDAR",
495            })
496        );
497        assert_eq!(parser.next(), None);
498    }
499
500    #[test]
501    fn test_empty_data() {
502        let data = "";
503        let mut parser = Parser::new(data);
504        assert_eq!(parser.next(), None);
505    }
506
507    #[test]
508    fn test_empty_lines() {
509        // A line followed by CRLF is a different code-path than a line followed by EOF.
510        let data = "\r\n";
511        let mut parser = Parser::new(data);
512        let line = parser.next().unwrap();
513        assert_eq!(
514            line,
515            ContentLine {
516                raw: "",
517                name: "",
518                params: "",
519                value: "",
520            }
521        );
522        assert_eq!(line.raw(), "");
523        assert_eq!(line.name(), "");
524        assert_eq!(line.params(), "");
525        assert_eq!(line.value(), "");
526        // FIXME: trailing empty lines are swallowed.
527        // assert_eq!(
528        //     parser.next(),
529        //     Some(ContentLine {
530        //         raw: "",
531        //         name: "",
532        //         params: "",
533        //         value: "",
534        //     })
535        // );
536        assert_eq!(parser.next(), None);
537    }
538
539    #[test]
540    fn test_line_with_params() {
541        // A line with ending in CRLF is a different code-path than a line in EOF.
542        let data = [
543            "DTSTART;TZID=America/New_York:19970902T090000",
544            "DTSTART;TZID=America/New_York:19970902T090000",
545        ]
546        .join("\r\n");
547        let mut parser = Parser::new(&data);
548        assert_eq!(
549            parser.next(),
550            Some(ContentLine {
551                raw: "DTSTART;TZID=America/New_York:19970902T090000",
552                name: "DTSTART",
553                params: "TZID=America/New_York",
554                value: "19970902T090000",
555            })
556        );
557        assert_eq!(
558            parser.next(),
559            Some(ContentLine {
560                raw: "DTSTART;TZID=America/New_York:19970902T090000",
561                name: "DTSTART",
562                params: "TZID=America/New_York",
563                value: "19970902T090000",
564            })
565        );
566        assert_eq!(parser.next(), None);
567    }
568
569    #[test]
570    fn test_line_with_dquote() {
571        // A line with ending in CRLF is a different code-path than a line in EOF.
572        let data = [
573            "SUMMARY:This has \"some quotes\"",
574            "DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
575        ]
576        .join("\r\n");
577        let mut parser = Parser::new(&data);
578        assert_eq!(
579            parser.next(),
580            Some(ContentLine {
581                raw: "SUMMARY:This has \"some quotes\"",
582                name: "SUMMARY",
583                params: "",
584                value: "This has \"some quotes\"",
585            })
586        );
587        assert_eq!(
588            parser.next(),
589            Some(ContentLine {
590                raw: "DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
591                name: "DTSTART",
592                params: "TZID=\"local;VALUE=DATE-TIME\"",
593                value: "20150304T184500",
594            })
595        );
596        assert_eq!(parser.next(), None);
597    }
598
599    #[test]
600    fn test_continuation_line() {
601        // A line with ending in CRLF is a different code-path than a line in EOF.
602        let data = [
603            "X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
604            " X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
605            "X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
606            " X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
607        ]
608        .join("\r\n");
609        let mut parser = Parser::new(&data);
610        assert_eq!(
611            parser.next(),
612            Some(ContentLine {
613                raw: &[
614                    "X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
615                    " X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
616                ]
617                .join("\r\n"),
618                name: "X-JMAP-LOCATION",
619                params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\r\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
620                value: "Name of place",
621            })
622        );
623        assert_eq!(
624            parser.next(),
625            Some(ContentLine {
626                raw: &[
627                    "X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
628                    " X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
629                ]
630                .join("\r\n"),
631                name: "X-JMAP-LOCATION",
632                params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\r\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
633                value: "Name of place",
634            })
635        );
636        assert_eq!(parser.next(), None);
637    }
638
639    #[test]
640    fn test_invalid_lone_name() {
641        let data = "BEGIN";
642        let mut parser = Parser::new(data);
643        assert_eq!(
644            parser.next(),
645            Some(ContentLine {
646                raw: "BEGIN",
647                name: "BEGIN",
648                params: "",
649                value: "",
650            })
651        );
652        assert_eq!(parser.next(), None);
653    }
654
655    #[test]
656    fn test_invalid_name_with_params() {
657        let data = "DTSTART;TZID=America/New_York";
658        let mut parser = Parser::new(data);
659        assert_eq!(
660            parser.next(),
661            Some(ContentLine {
662                raw: "DTSTART;TZID=America/New_York",
663                name: "DTSTART",
664                params: "TZID=America/New_York",
665                value: "",
666            })
667        );
668        assert_eq!(parser.next(), None);
669    }
670
671    #[test]
672    fn test_invalid_name_with_trailing_semicolon() {
673        let data = "DTSTART;";
674        let mut parser = Parser::new(data);
675        assert_eq!(
676            parser.next(),
677            Some(ContentLine {
678                raw: "DTSTART;",
679                name: "DTSTART",
680                params: "",
681                value: "",
682            })
683        );
684        assert_eq!(parser.next(), None);
685    }
686
687    #[test]
688    fn test_invalid_name_with_trailing_colon() {
689        let data = "DTSTART:";
690        let mut parser = Parser::new(data);
691        assert_eq!(
692            parser.next(),
693            Some(ContentLine {
694                raw: "DTSTART:",
695                name: "DTSTART",
696                params: "",
697                value: "",
698            })
699        );
700        assert_eq!(parser.next(), None);
701    }
702
703    #[test]
704    fn test_remainder() {
705        let data = ["BEGIN:VTODO", "SUMMARY:Do the thing"].join("\r\n");
706        let mut parser = Parser::new(&data);
707        assert_eq!(
708            parser.next(),
709            Some(ContentLine {
710                raw: "BEGIN:VTODO",
711                name: "BEGIN",
712                params: "",
713                value: "VTODO",
714            })
715        );
716        assert_eq!(parser.remainder(), "SUMMARY:Do the thing");
717        assert_eq!(
718            parser.next(),
719            Some(ContentLine {
720                raw: "SUMMARY:Do the thing",
721                name: "SUMMARY",
722                params: "",
723                value: "Do the thing",
724            })
725        );
726        assert_eq!(parser.next(), None);
727    }
728
729    #[test]
730    fn test_fold_multiline() {
731        assert_eq!(
732            unfold_lines("UID:horrible-\r\n example"),
733            "UID:horrible-example"
734        );
735        assert_eq!(unfold_lines("UID:X\r\n Y"), "UID:XY");
736        assert_eq!(unfold_lines("UID:X\r\n "), "UID:X");
737        assert_eq!(
738            unfold_lines("UID:quite\r\n a\r\n few\r\n lines"),
739            "UID:quiteafewlines"
740        );
741    }
742
743    #[test]
744    #[should_panic(expected = "continuation line is not a continuation line")]
745    fn test_fold_multiline_missing_whitespace() {
746        unfold_lines("UID:two\r\nlines");
747    }
748
749    #[test]
750    fn test_normalise_folds_short() {
751        let data = "SUMMARY:Hello there";
752        let mut parser = Parser::new(data);
753        let line = parser.next().unwrap();
754        assert_eq!(parser.next(), None);
755
756        assert_eq!(line.normalise_folds(), data);
757        assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
758    }
759
760    #[test]
761    fn test_normalise_folds_with_carrige_returns() {
762        let data = "SUMMARY:Hello \rthere";
763        let mut parser = Parser::new(data);
764        let line = parser.next().unwrap();
765        assert_eq!(parser.next(), None);
766
767        assert_eq!(line.normalise_folds(), data);
768        assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
769    }
770
771    #[test]
772    fn test_normalise_folds_with_newlines() {
773        let data = "SUMMARY:Hello \nthere";
774        let mut parser = Parser::new(data);
775        let line = parser.next().unwrap();
776        assert_eq!(parser.next(), None);
777
778        assert_eq!(line.normalise_folds(), data);
779        assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
780    }
781
782    #[test]
783    fn test_normalise_folds_too_many_folds() {
784        let data = "SUMMARY:Hello \r\n \r\n there";
785        let mut parser = Parser::new(data);
786        let line = parser.next().unwrap();
787        assert_eq!(parser.next(), None);
788
789        let expected = "SUMMARY:Hello there";
790        assert_eq!(line.normalise_folds(), expected);
791    }
792
793    #[test]
794    fn test_normalise_folds_long() {
795        let data = [
796            "SUMMARY:Some really long text that nobody ",
797            " cares about, but is wrapped in two lines.",
798        ]
799        .join("\r\n");
800        let mut parser = Parser::new(&data);
801        let line = parser.next().unwrap();
802        assert_eq!(parser.next(), None);
803
804        let expected = [
805            "SUMMARY:Some really long text that nobody cares about, but is wrapped in t",
806            " wo lines.",
807        ]
808        .join("\r\n");
809        assert_eq!(line.normalise_folds(), expected);
810    }
811
812    #[test]
813    fn test_normalise_folds_multibyte() {
814        // This is 59 characters, 161 octets
815        let data = "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已升斤法兌。";
816        let mut parser = Parser::new(data);
817        let line = parser.next().unwrap();
818        assert_eq!(parser.next(), None);
819
820        let expected = [
821            // Keep in mind that CR counts, but LF does not.
822            "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", // 74
823            " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已",    // 73
824            " 升斤法兌。",                                          // 16
825        ]
826        .join("\r\n");
827        assert_eq!(line.normalise_folds(), expected);
828    }
829
830    #[test]
831    fn test_normalise_folds_multibyte_noop() {
832        // This is 59 characters, 161 octets
833        let data = [
834            // Keep in mind that CR counts, but LF does not.
835            "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", // 74
836            " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已",    // 73
837            " 升斤法兌。",                                          // 16
838        ]
839        .join("\r\n");
840        let mut parser = Parser::new(&data);
841        let line = parser.next().unwrap();
842        assert_eq!(parser.next(), None);
843
844        assert_eq!(line.normalise_folds(), data);
845        assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
846    }
847
848    #[test]
849    fn test_unfold_params_with_trailing_crlf() {
850        let data = ";\r\n";
851        let mut parser = Parser::new(data);
852        let line = parser.next().unwrap();
853        assert_eq!(line.raw(), ";");
854        assert_eq!(line.name(), "");
855        assert_eq!(line.params(), "");
856        assert_eq!(line.value(), "");
857    }
858
859    #[test]
860    fn test_unfold_name_with_trailing_crlf() {
861        let data = "\r\n";
862        let mut parser = Parser::new(data);
863        let line = parser.next().unwrap();
864        assert_eq!(line.raw(), "");
865        assert_eq!(line.name(), "");
866        assert_eq!(line.params(), "");
867        assert_eq!(line.value(), "");
868    }
869
870    #[test]
871    fn test_unfold_value_with_trailing_crlf() {
872        let data = ";:\r\n";
873        let mut parser = Parser::new(data);
874        let line = parser.next().unwrap();
875        assert_eq!(line.raw(), ";:");
876        assert_eq!(line.name(), "");
877        assert_eq!(line.params(), "");
878        assert_eq!(line.value(), "");
879    }
880}