1#![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#[derive(Debug, PartialEq, Clone)]
26pub struct ContentLine<'input> {
27 raw: &'input str,
30 name: &'input str,
32 params: &'input str,
34 value: &'input str,
36}
37
38impl<'input> ContentLine<'input> {
39 #[must_use]
41 pub fn raw(&self) -> &'input str {
42 self.raw
43 }
44
45 #[must_use]
47 pub fn name(&self) -> Cow<'input, str> {
48 unfold_lines(self.name)
49 }
50
51 #[must_use]
53 pub fn params(&self) -> Cow<'input, str> {
54 unfold_lines(self.params)
55 }
56
57 #[must_use]
59 pub fn value(&self) -> Cow<'input, str> {
60 unfold_lines(self.value)
61 }
62
63 #[must_use]
68 pub fn unfolded(&self) -> Cow<'input, str> {
69 unfold_lines(self.raw)
70 }
71
72 #[must_use]
78 #[allow(clippy::missing_panics_doc)]
80 pub fn normalise_folds(&self) -> Cow<'input, str> {
81 let mut result = Cow::Borrowed(self.raw);
82 let mut cur = 0;
84 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(); 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 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 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; }
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
144pub struct Parser<'data> {
164 data: &'data str,
165 characters: Peekable<Enumerate<Bytes<'data>>>,
166}
167
168impl<'data> Parser<'data> {
169 #[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 #[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 #[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; };
215 self.characters.next(); if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
217 continue; }
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 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; };
256 self.characters.next(); if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
258 continue; }
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 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; };
285 self.characters.next(); if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
287 continue; }
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; };
311 self.characters.next(); if matches!(self.characters.peek(), Some((_, b' ' | b'\t'))) {
313 continue; }
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
338fn 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; };
355 chars.next(); 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 ]
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 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 assert_eq!(parser.next(), None);
537 }
538
539 #[test]
540 fn test_line_with_params() {
541 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 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 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 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 "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
826 .join("\r\n");
827 assert_eq!(line.normalise_folds(), expected);
828 }
829
830 #[test]
831 fn test_normalise_folds_multibyte_noop() {
832 let data = [
834 "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
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}