use super::{IcalError, RawComponent, RawProperty};
pub fn parse_calendar(input: &str) -> Result<RawComponent, IcalError> {
let logical_lines = unfold(input);
let mut stack: Vec<RawComponent> = Vec::new();
for line in logical_lines {
if line.is_empty() {
continue;
}
let prop = parse_property_line(&line)?;
if prop.name.eq_ignore_ascii_case("BEGIN") {
stack.push(RawComponent {
name: compact_str::CompactString::new(&prop.value),
properties: Vec::new(),
children: Vec::new(),
});
} else if prop.name.eq_ignore_ascii_case("END") {
let finished = stack.pop().ok_or_else(|| {
IcalError::InvalidSyntax(format!("END:{} without matching BEGIN", prop.value))
})?;
if !finished.name.eq_ignore_ascii_case(&prop.value) {
return Err(IcalError::InvalidSyntax(format!(
"END:{} closes BEGIN:{}",
prop.value, finished.name
)));
}
match stack.last_mut() {
Some(parent) => parent.children.push(finished),
None => {
if !finished.name.eq_ignore_ascii_case("VCALENDAR") {
return Err(IcalError::InvalidSyntax(format!(
"outermost component is {}, expected VCALENDAR",
finished.name
)));
}
return Ok(finished);
}
}
} else {
let current = stack.last_mut().ok_or_else(|| {
IcalError::InvalidSyntax(format!(
"property {} appears outside any component",
prop.name
))
})?;
current.properties.push(prop);
}
}
if !stack.is_empty() {
return Err(IcalError::InvalidSyntax(format!(
"unclosed BEGIN:{}",
stack.last().expect("non-empty").name
)));
}
Err(IcalError::InvalidSyntax(
"no VCALENDAR component found".into(),
))
}
fn unfold(input: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut current = String::new();
for raw_line in split_lines(input) {
if let Some(rest) = raw_line
.strip_prefix(' ')
.or_else(|| raw_line.strip_prefix('\t'))
{
current.push_str(rest);
} else {
if !current.is_empty() || !out.is_empty() || !raw_line.is_empty() {
out.push(std::mem::take(&mut current));
}
current.push_str(raw_line);
}
}
if !current.is_empty() {
out.push(current);
}
out.into_iter().filter(|s| !s.is_empty()).collect()
}
fn split_lines(input: &str) -> impl Iterator<Item = &str> {
input
.split_terminator('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
}
fn parse_property_line(line: &str) -> Result<RawProperty, IcalError> {
let bytes = line.as_bytes();
let mut i = 0;
let mut in_quotes = false;
let mut value_start: Option<usize> = None;
while i < bytes.len() {
let b = bytes[i];
if b == b'"' {
in_quotes = !in_quotes;
} else if b == b':' && !in_quotes {
value_start = Some(i);
break;
}
i += 1;
}
let value_start = value_start.ok_or_else(|| {
IcalError::InvalidSyntax(format!("property line missing ':' separator: {line}"))
})?;
let header = &line[..value_start];
let value = &line[value_start + 1..];
let header_parts = split_unquoted_semicolons(header);
let mut iter = header_parts.into_iter();
let name = compact_str::CompactString::new(
iter.next()
.ok_or_else(|| IcalError::InvalidSyntax(format!("empty property header: {line}")))?,
);
if name.is_empty() {
return Err(IcalError::InvalidSyntax(format!(
"empty property name in: {line}"
)));
}
let mut params: Vec<(compact_str::CompactString, compact_str::CompactString)> = Vec::new();
for raw in iter {
if let Some(eq) = raw.find('=') {
let pname = compact_str::CompactString::new(&raw[..eq]);
let pval = unquote_param(&raw[eq + 1..]);
params.push((pname, pval));
} else {
params.push((
compact_str::CompactString::new(raw),
compact_str::CompactString::default(),
));
}
}
Ok(RawProperty {
name,
params,
value: value.to_string(),
})
}
fn split_unquoted_semicolons(header: &str) -> Vec<&str> {
let bytes = header.as_bytes();
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'"' {
in_quotes = !in_quotes;
} else if b == b';' && !in_quotes {
parts.push(&header[start..i]);
start = i + 1;
}
i += 1;
}
parts.push(&header[start..]);
parts
}
fn unquote_param(s: &str) -> compact_str::CompactString {
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
compact_str::CompactString::new(&s[1..s.len() - 1])
} else {
compact_str::CompactString::new(s)
}
}
#[cfg(test)]
mod parse_tests {
use super::*;
#[test]
fn parses_single_property() {
let p = parse_property_line("UID:abc-123").unwrap();
assert_eq!(p.name, "UID");
assert_eq!(p.value, "abc-123");
assert!(p.params.is_empty());
}
#[test]
fn parses_property_with_params() {
let p = parse_property_line("ATTENDEE;CN=John Doe;PARTSTAT=ACCEPTED:mailto:j@example.com")
.unwrap();
assert_eq!(p.name, "ATTENDEE");
assert_eq!(p.value, "mailto:j@example.com");
assert_eq!(p.params.len(), 2);
assert_eq!(p.params[0].0, "CN");
assert_eq!(p.params[0].1, "John Doe");
assert_eq!(p.params[1].0, "PARTSTAT");
assert_eq!(p.params[1].1, "ACCEPTED");
}
#[test]
fn handles_quoted_param_with_colon() {
let p = parse_property_line("X-FOO;BAR=\"weird:value;with-stuff\":real-value").unwrap();
assert_eq!(p.name, "X-FOO");
assert_eq!(p.value, "real-value");
assert_eq!(p.params[0].1, "weird:value;with-stuff");
}
#[test]
fn unfolds_continuation() {
let lines = unfold("VERSION:2.0\r\nSUMMARY:long\r\n part 2\r\n part 3\r\n");
assert_eq!(lines, vec!["VERSION:2.0", "SUMMARY:long part 2 part 3"]);
}
#[test]
fn unfolds_with_tab_continuation() {
let lines = unfold("FOO:bar\r\n\tcontinued\r\n");
assert_eq!(lines, vec!["FOO:barcontinued"]);
}
#[test]
fn parse_property_line_missing_colon_errors() {
let r = parse_property_line("UID-without-colon");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_property_line_empty_name_errors() {
let r = parse_property_line(":just-value");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_property_line_bare_flag_param() {
let p = parse_property_line("X-FLAG;BARE:value").unwrap();
assert_eq!(p.name, "X-FLAG");
assert_eq!(p.params.len(), 1);
assert_eq!(p.params[0].0, "BARE");
assert_eq!(p.params[0].1, "");
assert_eq!(p.value, "value");
}
#[test]
fn parse_property_line_empty_value() {
let p = parse_property_line("UID:").unwrap();
assert_eq!(p.name, "UID");
assert_eq!(p.value, "");
}
#[test]
fn parse_property_line_value_containing_colons() {
let p = parse_property_line("URL:https://example.com").unwrap();
assert_eq!(p.name, "URL");
assert_eq!(p.value, "https://example.com");
}
#[test]
fn parse_calendar_no_vcalendar_errors() {
let r = parse_calendar("VERSION:2.0\r\n");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_calendar_unclosed_begin_errors() {
let r = parse_calendar("BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nEND:VCALENDAR\r\n");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_calendar_mismatched_end_errors() {
let r = parse_calendar(
"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nEND:VTODO\r\nEND:VCALENDAR\r\n",
);
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_calendar_property_outside_component_errors() {
let r = parse_calendar("UID:x\r\nBEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_calendar_outermost_not_vcalendar_errors() {
let r = parse_calendar("BEGIN:VEVENT\r\nUID:x\r\nEND:VEVENT\r\n");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn parse_calendar_end_without_begin_errors() {
let r = parse_calendar("END:VEVENT\r\n");
assert!(matches!(r, Err(IcalError::InvalidSyntax(_))));
}
#[test]
fn split_unquoted_semicolons_handles_quotes() {
let parts = split_unquoted_semicolons("CN=\"Doe;Jane\";PARTSTAT=ACCEPTED");
assert_eq!(parts.len(), 2);
assert_eq!(parts[0], "CN=\"Doe;Jane\"");
assert_eq!(parts[1], "PARTSTAT=ACCEPTED");
}
#[test]
fn unquote_param_strips_surrounding_quotes_only() {
assert_eq!(unquote_param("\"hello\""), "hello");
assert_eq!(unquote_param("no-quotes"), "no-quotes");
assert_eq!(unquote_param("a\"b\"c"), "a\"b\"c");
assert_eq!(unquote_param("\"unclosed"), "\"unclosed");
}
#[test]
fn unfold_empty_input() {
let lines = unfold("");
assert!(lines.is_empty());
}
#[test]
fn unfold_preserves_blank_separator_handling() {
let lines = unfold("FOO:bar\r\n\r\nBAZ:qux\r\n");
assert_eq!(lines, vec!["FOO:bar", "BAZ:qux"]);
}
}