#![no_std]
#![deny(clippy::pedantic)]
#![allow(clippy::nonminimal_bool)]
extern crate alloc;
use core::iter::FusedIterator;
use alloc::{
borrow::{Cow, ToOwned},
string::ToString,
};
#[derive(Debug, PartialEq, Clone)]
pub struct ContentLine<'input> {
raw: &'input str,
name: &'input str,
params: &'input str,
value: &'input str,
}
impl<'input> ContentLine<'input> {
#[must_use]
pub fn raw(&self) -> &'input str {
self.raw
}
#[must_use]
pub fn name(&self) -> Cow<'input, str> {
unfold_lines(self.name)
}
#[must_use]
pub fn params(&self) -> Cow<'input, str> {
unfold_lines(self.params)
}
#[must_use]
pub fn value(&self) -> Cow<'input, str> {
unfold_lines(self.value)
}
#[must_use]
pub fn unfolded(&self) -> Cow<'input, str> {
unfold_lines(self.raw)
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn normalise_folds(&self) -> Cow<'input, str> {
let mut result = Cow::Borrowed(self.raw);
let mut cur = 0;
let mut line_start = 0;
let mut chars = self.raw.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let skip_bytes = if c == '\r' && matches!(chars.peek(), Some((_, '\n'))) {
chars.next(); 3
} else if c == '\n' {
2
} else if (i - line_start) + c.len_utf8() >= 75 {
let portion = &self.raw[cur..i];
match result {
Cow::Borrowed(_) => {
result = Cow::Owned(portion.to_string() + "\r\n " + &c.to_string());
}
Cow::Owned(ref mut s) => {
s.push_str(portion);
s.push_str("\r\n ");
s.push(c);
}
}
cur = i + c.len_utf8();
line_start = i - 1; continue;
} else {
continue;
};
assert!(
matches!(chars.next(), Some((_, ' ' | '\t'))),
"continuation line must start with a space or tab",
);
let next = chars.peek().map_or(1, |(_, c)| c.len_utf8());
if i - line_start + next >= 75 {
line_start = i + skip_bytes - 1;
} else {
let portion = &self.raw[cur..i];
match result {
Cow::Borrowed(_) => {
result = Cow::Owned(portion.to_owned());
}
Cow::Owned(ref mut s) => {
s.push_str(portion);
}
}
line_start += skip_bytes;
cur = i + skip_bytes;
}
}
if let Cow::Owned(ref mut s) = result {
let portion = &self.raw[cur..];
s.push_str(portion);
}
result
}
}
pub struct Parser<'data> {
data: &'data str,
pos: usize,
}
impl<'data> Parser<'data> {
#[must_use]
pub fn new(data: &'data str) -> Parser<'data> {
Parser { data, pos: 0 }
}
#[must_use]
pub fn remainder(&self) -> &str {
&self.data[self.pos..]
}
}
impl<'data> Iterator for Parser<'data> {
type Item = ContentLine<'data>;
#[allow(clippy::too_many_lines)]
fn next(&mut self) -> Option<ContentLine<'data>> {
let bytes = self.data.as_bytes();
let len = bytes.len();
let start = self.pos;
if start >= len {
return None;
}
let mut pos = start;
let mut semicolon = None;
while pos < len {
let b = bytes[pos];
pos += 1;
if b == b';' {
semicolon = Some(pos - 1);
break;
}
if b == b':' {
return Some(self.parse_value(start, pos - 1, None));
}
if b == b'\r' {
if pos < len && bytes[pos] == b'\n' {
pos += 1;
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return Some(ContentLine {
raw: &self.data[start..pos - 2],
name: &self.data[start..pos - 2],
params: &self.data[start..start],
value: &self.data[start..start],
});
}
}
if b == b'\n' {
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return Some(ContentLine {
raw: &self.data[start..pos - 1],
name: &self.data[start..pos - 1],
params: &self.data[start..start],
value: &self.data[start..start],
});
}
}
if semicolon.is_none() {
self.pos = len;
return Some(ContentLine {
raw: &self.data[start..],
name: &self.data[start..],
params: &self.data[start..start],
value: &self.data[start..start],
});
}
let semicolon_idx = semicolon.unwrap();
while pos < len {
let b = bytes[pos];
pos += 1;
if b == b'"' {
while pos < len {
if bytes[pos] == b'"' {
pos += 1;
break;
}
pos += 1;
}
continue;
}
if b == b':' {
return Some(self.parse_value(start, pos - 1, Some(semicolon_idx)));
}
if b == b'\r' {
if pos < len && bytes[pos] == b'\n' {
pos += 1;
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return Some(ContentLine {
raw: &self.data[start..pos - 2],
name: &self.data[start..semicolon_idx],
params: &self.data[semicolon_idx + 1..pos - 2],
value: &self.data[semicolon_idx..semicolon_idx],
});
}
}
if b == b'\n' {
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return Some(ContentLine {
raw: &self.data[start..pos - 1],
name: &self.data[start..semicolon_idx],
params: &self.data[semicolon_idx + 1..pos - 1],
value: &self.data[semicolon_idx..semicolon_idx],
});
}
}
self.pos = len;
if let Some(semicolon_idx) = semicolon {
Some(ContentLine {
raw: &self.data[start..],
name: &self.data[start..semicolon_idx],
params: &self.data[semicolon_idx + 1..],
value: &self.data[semicolon_idx..semicolon_idx],
})
} else {
Some(ContentLine {
raw: &self.data[start..],
name: &self.data[start..],
params: &self.data[start..start],
value: &self.data[start..start],
})
}
}
}
impl<'data> Parser<'data> {
fn parse_value(
&mut self,
start: usize,
colon: usize,
semicolon: Option<usize>,
) -> ContentLine<'data> {
let bytes = self.data.as_bytes();
let len = bytes.len();
let mut pos = colon + 1;
while pos < len {
let b = bytes[pos];
pos += 1;
if b == b'\r' {
if pos < len && bytes[pos] == b'\n' {
pos += 1;
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return self.make_line(start, colon, semicolon, pos - 2);
}
}
if b == b'\n' {
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
continue;
}
self.pos = pos;
return self.make_line(start, colon, semicolon, pos - 1);
}
}
self.pos = len;
self.make_line(start, colon, semicolon, len)
}
fn make_line(
&self,
start: usize,
colon: usize,
semicolon: Option<usize>,
raw_end: usize,
) -> ContentLine<'data> {
match semicolon {
Some(semicolon_idx) => ContentLine {
raw: &self.data[start..raw_end],
name: &self.data[start..semicolon_idx],
params: &self.data[semicolon_idx + 1..colon],
value: &self.data[colon + 1..raw_end],
},
None => ContentLine {
raw: &self.data[start..raw_end],
name: &self.data[start..colon],
params: &self.data[colon..colon],
value: &self.data[colon + 1..raw_end],
},
}
}
}
impl FusedIterator for Parser<'_> {}
fn unfold_lines(lines: &str) -> Cow<'_, str> {
let bytes = lines.as_bytes();
let len = bytes.len();
let mut result = Cow::Borrowed(lines);
let mut cur = 0;
let mut pos = 0;
while pos < len {
let b = bytes[pos];
pos += 1;
if b == b'\r' {
if pos < len && bytes[pos] == b'\n' {
pos += 1;
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
pos += 1;
let i = pos - 3; let portion = &lines[cur..i];
match result {
Cow::Borrowed(_) => {
result = Cow::Owned(portion.to_owned());
}
Cow::Owned(ref mut s) => {
s.push_str(portion);
}
}
cur = pos;
} else {
panic!("continuation line is not a continuation line");
}
}
} else if b == b'\n' {
if pos < len && (bytes[pos] == b' ' || bytes[pos] == b'\t') {
pos += 1;
let i = pos - 2; let portion = &lines[cur..i];
match result {
Cow::Borrowed(_) => {
result = Cow::Owned(portion.to_owned());
}
Cow::Owned(ref mut s) => {
s.push_str(portion);
}
}
cur = pos;
} else {
panic!("continuation line is not a continuation line");
}
}
}
if let Cow::Owned(ref mut s) = result {
let portion = &lines[cur..];
s.push_str(portion);
}
result
}
pub struct ParamIter<'a> {
input: &'a str,
pos: usize,
}
impl<'a> Iterator for ParamIter<'a> {
type Item = Param<'a>;
fn next(&mut self) -> Option<Self::Item> {
if self.pos >= self.input.len() {
return None;
}
let bytes = self.input.as_bytes();
let mut equals = None;
let mut end_pos = None;
let mut in_quotes = false;
for (i, b) in bytes.iter().enumerate().skip(self.pos) {
match b {
b'"' => in_quotes = !in_quotes,
b'=' if equals.is_none() && !in_quotes => equals = Some(i),
b';' if !in_quotes => {
end_pos = Some(i);
break;
}
_ => {}
}
}
let start = self.pos;
let end = end_pos.unwrap_or(bytes.len());
self.pos = if let Some(e) = end_pos {
e + 1
} else {
bytes.len()
};
let equals = equals?;
Some(Param {
name: &self.input[start..equals],
value: &self.input[equals + 1..end],
})
}
}
impl FusedIterator for ParamIter<'_> {}
pub struct Param<'a> {
name: &'a str,
value: &'a str,
}
impl<'a> Param<'a> {
#[must_use]
pub fn name(&self) -> &'a str {
self.name
}
#[must_use]
pub fn value(&self) -> &'a str {
self.value
}
}
impl<'a> ParamIter<'a> {
#[must_use]
pub fn new(input: &'a str) -> Self {
Self { input, pos: 0 }
}
}
#[cfg(test)]
#[allow(clippy::too_many_lines)]
mod test {
use crate::{unfold_lines, ContentLine, ParamIter, Parser};
use alloc::borrow::Cow;
#[test]
fn test_complete_example() {
let data = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:nl.whynothugo.todoman",
"BEGIN:VTODO",
"DTSTAMP:20231126T095923Z",
"DUE;TZID=Asia/Shanghai:20231128T090000",
"SUMMARY:dummy todo for parser tests",
"UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
"END:VTODO",
"END:VCALENDAR",
]
.join("\r\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN:VCALENDAR",
name: "BEGIN",
params: "",
value: "VCALENDAR"
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "VERSION:2.0",
name: "VERSION",
params: "",
value: "2.0",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "PRODID:nl.whynothugo.todoman",
name: "PRODID",
params: "",
value: "nl.whynothugo.todoman",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN:VTODO",
name: "BEGIN",
params: "",
value: "VTODO",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTAMP:20231126T095923Z",
name: "DTSTAMP",
params: "",
value: "20231126T095923Z",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DUE;TZID=Asia/Shanghai:20231128T090000",
name: "DUE",
params: "TZID=Asia/Shanghai",
value: "20231128T090000",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "SUMMARY:dummy todo for parser tests",
name: "SUMMARY",
params: "",
value: "dummy todo for parser tests",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
name: "UID",
params: "",
value: "565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "END:VTODO",
name: "END",
params: "",
value: "VTODO",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "END:VCALENDAR",
name: "END",
params: "",
value: "VCALENDAR",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_empty_data() {
let data = "";
let mut parser = Parser::new(data);
assert_eq!(parser.next(), None);
}
#[test]
fn test_empty_lines() {
let data = "\r\n";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(
line,
ContentLine {
raw: "",
name: "",
params: "",
value: "",
}
);
assert_eq!(line.raw(), "");
assert_eq!(line.name(), "");
assert_eq!(line.params(), "");
assert_eq!(line.value(), "");
assert_eq!(parser.next(), None);
}
#[test]
fn test_line_with_params() {
let data = [
"DTSTART;TZID=America/New_York:19970902T090000",
"DTSTART;TZID=America/New_York:19970902T090000",
]
.join("\r\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=America/New_York:19970902T090000",
name: "DTSTART",
params: "TZID=America/New_York",
value: "19970902T090000",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=America/New_York:19970902T090000",
name: "DTSTART",
params: "TZID=America/New_York",
value: "19970902T090000",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_line_with_dquote() {
let data = [
"SUMMARY:This has \"some quotes\"",
"DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
]
.join("\r\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "SUMMARY:This has \"some quotes\"",
name: "SUMMARY",
params: "",
value: "This has \"some quotes\"",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
name: "DTSTART",
params: "TZID=\"local;VALUE=DATE-TIME\"",
value: "20150304T184500",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_continuation_line() {
let data = [
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\r\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: &[
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\r\n"),
name: "X-JMAP-LOCATION",
params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\r\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
value: "Name of place",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: &[
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\r\n"),
name: "X-JMAP-LOCATION",
params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\r\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
value: "Name of place",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_invalid_lone_name() {
let data = "BEGIN";
let mut parser = Parser::new(data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN",
name: "BEGIN",
params: "",
value: "",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_invalid_name_with_params() {
let data = "DTSTART;TZID=America/New_York";
let mut parser = Parser::new(data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=America/New_York",
name: "DTSTART",
params: "TZID=America/New_York",
value: "",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_invalid_name_with_trailing_semicolon() {
let data = "DTSTART;";
let mut parser = Parser::new(data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;",
name: "DTSTART",
params: "",
value: "",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_invalid_name_with_trailing_colon() {
let data = "DTSTART:";
let mut parser = Parser::new(data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART:",
name: "DTSTART",
params: "",
value: "",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_remainder() {
let data = ["BEGIN:VTODO", "SUMMARY:Do the thing"].join("\r\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN:VTODO",
name: "BEGIN",
params: "",
value: "VTODO",
})
);
assert_eq!(parser.remainder(), "SUMMARY:Do the thing");
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "SUMMARY:Do the thing",
name: "SUMMARY",
params: "",
value: "Do the thing",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_fold_multiline() {
assert_eq!(
unfold_lines("UID:horrible-\r\n example"),
"UID:horrible-example"
);
assert_eq!(unfold_lines("UID:X\r\n Y"), "UID:XY");
assert_eq!(unfold_lines("UID:X\r\n "), "UID:X");
assert_eq!(
unfold_lines("UID:quite\r\n a\r\n few\r\n lines"),
"UID:quiteafewlines"
);
}
#[test]
#[should_panic(expected = "continuation line is not a continuation line")]
fn test_fold_multiline_missing_whitespace() {
unfold_lines("UID:two\r\nlines");
}
#[test]
fn test_normalise_folds_short() {
let data = "SUMMARY:Hello there";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
assert_eq!(line.normalise_folds(), data);
assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
}
#[test]
fn test_normalise_folds_with_carrige_returns() {
let data = "SUMMARY:Hello \rthere";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
assert_eq!(line.normalise_folds(), data);
assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
}
#[test]
fn test_normalise_folds_with_newlines() {
let data = "SUMMARY:Hello \nthere";
let mut parser = Parser::new(data);
let line1 = parser.next().unwrap();
assert_eq!(line1.raw(), "SUMMARY:Hello ");
assert_eq!(line1.name(), "SUMMARY");
assert_eq!(line1.value(), "Hello ");
let line2 = parser.next().unwrap();
assert_eq!(line2.raw(), "there");
assert_eq!(line2.name(), "there");
assert_eq!(parser.next(), None);
}
#[test]
fn test_normalise_folds_too_many_folds() {
let data = "SUMMARY:Hello \r\n \r\n there";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = "SUMMARY:Hello there";
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_long() {
let data = [
"SUMMARY:Some really long text that nobody ",
" cares about, but is wrapped in two lines.",
]
.join("\r\n");
let mut parser = Parser::new(&data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = [
"SUMMARY:Some really long text that nobody cares about, but is wrapped in t",
" wo lines.",
]
.join("\r\n");
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_multibyte() {
let data = "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已升斤法兌。";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = [
"SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
.join("\r\n");
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_multibyte_noop() {
let data = [
"SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
.join("\r\n");
let mut parser = Parser::new(&data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
assert_eq!(line.normalise_folds(), data);
assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
}
#[test]
fn test_unfold_params_with_trailing_crlf() {
let data = ";\r\n";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(line.raw(), ";");
assert_eq!(line.name(), "");
assert_eq!(line.params(), "");
assert_eq!(line.value(), "");
}
#[test]
fn test_unfold_name_with_trailing_crlf() {
let data = "\r\n";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(line.raw(), "");
assert_eq!(line.name(), "");
assert_eq!(line.params(), "");
assert_eq!(line.value(), "");
}
#[test]
fn test_unfold_value_with_trailing_crlf() {
let data = ";:\r\n";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(line.raw(), ";:");
assert_eq!(line.name(), "");
assert_eq!(line.params(), "");
assert_eq!(line.value(), "");
}
#[test]
fn dtend_with_tzid() {
let param_str = "TZID=America/Argentina/Buenos_Aires";
let mut params = ParamIter::new(param_str);
let p = params.next().unwrap();
assert_eq!(p.name, "TZID");
assert_eq!(p.value, "America/Argentina/Buenos_Aires");
assert!(params.next().is_none());
}
#[test]
fn params_with_multiple() {
let param_str = "X-JMAP-ID=123;CN=Alice;EMAIL=alice@example.com;CUTYPE=INDIVIDUAL;X-JMAP-ROLE=attendee;PARTSTAT=TENTATIVE;X-DTSTAMP=20250614T210147Z;X-SEQUENCE=1";
let mut params = ParamIter::new(param_str);
let p = params.next().unwrap();
assert_eq!(p.name, "X-JMAP-ID");
assert_eq!(p.value, "123");
let p = params.next().unwrap();
assert_eq!(p.name, "CN");
assert_eq!(p.value, "Alice");
let p = params.next().unwrap();
assert_eq!(p.name, "EMAIL");
assert_eq!(p.value, "alice@example.com");
let p = params.next().unwrap();
assert_eq!(p.name, "CUTYPE");
assert_eq!(p.value, "INDIVIDUAL");
let p = params.next().unwrap();
assert_eq!(p.name, "X-JMAP-ROLE");
assert_eq!(p.value, "attendee");
let p = params.next().unwrap();
assert_eq!(p.name, "PARTSTAT");
assert_eq!(p.value, "TENTATIVE");
let p = params.next().unwrap();
assert_eq!(p.name, "X-DTSTAMP");
assert_eq!(p.value, "20250614T210147Z");
let p = params.next().unwrap();
assert_eq!(p.name, "X-SEQUENCE");
assert_eq!(p.value, "1");
assert!(params.next().is_none());
}
#[test]
fn quoted_params() {
let param_str = r#"X-CUSTOM="Alice;Bob;Charlie";ROLE=attendee;STATUS=TENTATIVE"#;
let mut params = ParamIter::new(param_str);
let p = params.next().unwrap();
assert_eq!(p.name, "X-CUSTOM");
assert_eq!(p.value, r#""Alice;Bob;Charlie""#);
let p = params.next().unwrap();
assert_eq!(p.name, "ROLE");
assert_eq!(p.value, "attendee");
let p = params.next().unwrap();
assert_eq!(p.name, "STATUS");
assert_eq!(p.value, "TENTATIVE");
assert!(params.next().is_none());
}
#[test]
fn broken_quoted_params() {
let param_str = r#"X-CUSTOM="Alice;Bob;Charlie"#;
let mut params = ParamIter::new(param_str);
let p = params.next().unwrap();
assert_eq!(p.name, "X-CUSTOM");
assert_eq!(p.value, r#""Alice;Bob;Charlie"#);
assert!(params.next().is_none());
}
#[test]
fn test_complete_example_lf() {
let data = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:nl.whynothugo.todoman",
"BEGIN:VTODO",
"DTSTAMP:20231126T095923Z",
"DUE;TZID=Asia/Shanghai:20231128T090000",
"SUMMARY:dummy todo for parser tests",
"UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
"END:VTODO",
"END:VCALENDAR",
]
.join("\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN:VCALENDAR",
name: "BEGIN",
params: "",
value: "VCALENDAR"
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "VERSION:2.0",
name: "VERSION",
params: "",
value: "2.0",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "PRODID:nl.whynothugo.todoman",
name: "PRODID",
params: "",
value: "nl.whynothugo.todoman",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "BEGIN:VTODO",
name: "BEGIN",
params: "",
value: "VTODO",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTAMP:20231126T095923Z",
name: "DTSTAMP",
params: "",
value: "20231126T095923Z",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DUE;TZID=Asia/Shanghai:20231128T090000",
name: "DUE",
params: "TZID=Asia/Shanghai",
value: "20231128T090000",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "SUMMARY:dummy todo for parser tests",
name: "SUMMARY",
params: "",
value: "dummy todo for parser tests",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "UID:565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
name: "UID",
params: "",
value: "565f48cb5b424815a2ba5e56555e2832@destiny.whynothugo.nl",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "END:VTODO",
name: "END",
params: "",
value: "VTODO",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "END:VCALENDAR",
name: "END",
params: "",
value: "VCALENDAR",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_empty_lines_lf() {
let data = "\n";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(
line,
ContentLine {
raw: "",
name: "",
params: "",
value: "",
}
);
assert_eq!(line.raw(), "");
assert_eq!(line.name(), "");
assert_eq!(line.params(), "");
assert_eq!(line.value(), "");
assert_eq!(parser.next(), None);
}
#[test]
fn test_line_with_params_lf() {
let data = [
"DTSTART;TZID=America/New_York:19970902T090000",
"DTSTART;TZID=America/New_York:19970902T090000",
]
.join("\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=America/New_York:19970902T090000",
name: "DTSTART",
params: "TZID=America/New_York",
value: "19970902T090000",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=America/New_York:19970902T090000",
name: "DTSTART",
params: "TZID=America/New_York",
value: "19970902T090000",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_line_with_dquote_lf() {
let data = [
"SUMMARY:This has \"some quotes\"",
"DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
]
.join("\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "SUMMARY:This has \"some quotes\"",
name: "SUMMARY",
params: "",
value: "This has \"some quotes\"",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: "DTSTART;TZID=\"local;VALUE=DATE-TIME\":20150304T184500",
name: "DTSTART",
params: "TZID=\"local;VALUE=DATE-TIME\"",
value: "20150304T184500",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_continuation_line_lf() {
let data = [
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\n");
let mut parser = Parser::new(&data);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: &[
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\n"),
name: "X-JMAP-LOCATION",
params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
value: "Name of place",
})
);
assert_eq!(
parser.next(),
Some(ContentLine {
raw: &[
"X-JMAP-LOCATION;VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";",
" X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c:Name of place",
]
.join("\n"),
name: "X-JMAP-LOCATION",
params: "VALUE=TEXT;X-JMAP-GEO=\"geo:52.123456,4.123456\";\n X-JMAP-ID=03453afa-71fc-4893-ba70-a7436bb6d56c",
value: "Name of place",
})
);
assert_eq!(parser.next(), None);
}
#[test]
fn test_fold_multiline_lf() {
assert_eq!(
unfold_lines("UID:horrible-\n example"),
"UID:horrible-example"
);
assert_eq!(unfold_lines("UID:X\n Y"), "UID:XY");
assert_eq!(unfold_lines("UID:X\n "), "UID:X");
assert_eq!(
unfold_lines("UID:quite\n a\n few\n lines"),
"UID:quiteafewlines"
);
}
#[test]
#[should_panic(expected = "continuation line is not a continuation line")]
fn test_fold_multiline_missing_whitespace_lf() {
unfold_lines("UID:two\nlines");
}
#[test]
fn test_normalise_folds_too_many_folds_lf() {
let data = "SUMMARY:Hello \n \n there";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = "SUMMARY:Hello there";
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_long_lf() {
let data = [
"SUMMARY:Some really long text that nobody ",
" cares about, but is wrapped in two lines.",
]
.join("\n");
let mut parser = Parser::new(&data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = [
"SUMMARY:Some really long text that nobody cares about, but is wrapped in t",
" wo lines.",
]
.join("\r\n");
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_multibyte_lf() {
let data = "SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已升斤法兌。";
let mut parser = Parser::new(data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
let expected = [
"SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
.join("\r\n");
assert_eq!(line.normalise_folds(), expected);
}
#[test]
fn test_normalise_folds_multibyte_noop_lf() {
let data = [
"SUMMARY:動千首看院波未假遠子到花,白六到星害,馬吃牠", " 說衣欠去皮香收司意,青個話化汁喜視娘以男雪青土已", " 升斤法兌。", ]
.join("\n");
let mut parser = Parser::new(&data);
let line = parser.next().unwrap();
assert_eq!(parser.next(), None);
assert_eq!(line.normalise_folds(), data);
assert!(matches!(line.normalise_folds(), Cow::Borrowed(_)));
}
#[test]
fn test_complete_example_mixed() {
let data = [
"BEGIN:VCALENDAR\r\n",
"VERSION:2.0\n",
"PRODID:nl.whynothugo.todoman\r\n",
"BEGIN:VTODO\n",
"DTSTAMP:20231126T095923Z\r\n",
"DUE;TZID=Asia/Shanghai:20231128T090000\n",
"SUMMARY:dummy todo for parser tests\r\n",
"END:VTODO\n",
"END:VCALENDAR",
]
.concat();
let mut parser = Parser::new(&data);
assert_eq!(parser.next().unwrap().name(), "BEGIN");
assert_eq!(parser.next().unwrap().name(), "VERSION");
assert_eq!(parser.next().unwrap().name(), "PRODID");
assert_eq!(parser.next().unwrap().name(), "BEGIN");
assert_eq!(parser.next().unwrap().name(), "DTSTAMP");
assert_eq!(parser.next().unwrap().name(), "DUE");
assert_eq!(parser.next().unwrap().name(), "SUMMARY");
assert_eq!(parser.next().unwrap().name(), "END");
assert_eq!(parser.next().unwrap().name(), "END");
assert_eq!(parser.next(), None);
}
#[test]
fn test_continuation_line_mixed() {
let data = "X-LONG;PARAM=value1;\r\n PARAM2=value2:Content here\nX-SHORT:Value\n";
let mut parser = Parser::new(data);
let line1 = parser.next().unwrap();
assert_eq!(line1.name(), "X-LONG");
assert_eq!(line1.value(), "Content here");
let line2 = parser.next().unwrap();
assert_eq!(line2.name(), "X-SHORT");
assert_eq!(line2.value(), "Value");
assert_eq!(parser.next(), None);
}
}