use core::fmt;
use alloc::{
borrow::Cow,
boxed::Box,
string::{String, ToString},
vec,
vec::Vec,
};
use crate::{
prop::IcalProp,
tree::{
codec::mode::Escaper, component::lens::IcalComponentLens, error::IcalParseError,
line::IcalLine, prop::lens::IcalPropLens,
},
version::IcalVersion,
};
#[derive(Clone, Debug)]
pub enum IcalItem<'a> {
Prop(IcalLine<'a>),
Component(Box<IcalCst<'a>>),
Opaque(Cow<'a, [u8]>),
}
#[derive(Clone, Debug)]
pub struct IcalCst<'a> {
pub begin: Option<IcalLine<'a>>,
pub items: Vec<IcalItem<'a>>,
pub end: Option<IcalLine<'a>>,
pub trailing: Cow<'a, str>,
}
impl<'a> IcalCst<'a> {
pub fn v2() -> Self {
Self {
begin: Some(IcalLine::text("BEGIN", "VCALENDAR")),
items: vec![IcalItem::Prop(IcalLine::text(
"VERSION",
&*IcalVersion::V2_0,
))],
end: Some(IcalLine::text("END", "VCALENDAR")),
trailing: Cow::Borrowed(""),
}
}
pub fn parse<T: AsRef<[u8]> + ?Sized>(input: &'a T) -> Result<Self, IcalParseError> {
let input = input.as_ref();
let (first, _rest) = IcalLine::take(input)?;
if first.name.get().eq_ignore_ascii_case("BEGIN") {
let (mut cst, rest) = Self::take_component(input)?;
cst.take_trailing(rest);
let escaper = Escaper::for_version_str(&cst.version_str());
cst.stamp_escaper(escaper);
Ok(cst)
} else {
Self::parse_bare(input)
}
}
fn parse_bare(input: &'a [u8]) -> Result<Self, IcalParseError> {
let mut items: Vec<IcalItem<'a>> = Vec::new();
let mut rest = input;
while !is_blank(rest) {
let (line, tail) = IcalLine::take(rest)?;
items.push(IcalItem::Prop(line));
rest = tail;
}
let mut cst = Self {
begin: None,
items,
end: None,
trailing: Cow::Borrowed(""),
};
cst.take_trailing(rest);
let escaper = Escaper::for_version_str(&cst.version_str());
cst.stamp_escaper(escaper);
Ok(cst)
}
pub fn parse_many<T: AsRef<[u8]> + ?Sized>(
input: &'a T,
) -> impl Iterator<Item = Result<Self, IcalParseError>> {
let mut rest = input.as_ref();
core::iter::from_fn(move || {
if is_blank(rest) {
return None;
}
match Self::take_component(rest) {
Ok((mut cst, tail)) => {
rest = cst.take_trailing(tail);
let escaper = Escaper::for_version_str(&cst.version_str());
cst.stamp_escaper(escaper);
Some(Ok(cst))
}
Err(error) => {
rest = b"";
Some(Err(error))
}
}
})
}
pub fn parse_recovering<T: AsRef<[u8]> + ?Sized>(input: &'a T) -> IcalRecovery<'a> {
let mut rest = input.as_ref();
let mut recovery = IcalRecovery::default();
let mut loose: Vec<IcalItem<'a>> = Vec::new();
while !is_blank(rest) {
match IcalLine::take(rest) {
Ok((line, _tail)) if line.name.get().eq_ignore_ascii_case("BEGIN") => {
recovery.close_loose(&mut loose);
let (mut cst, tail) = Self::take_component_recovering(rest, &mut recovery);
rest = tail;
let escaper = Escaper::for_version_str(&cst.version_str());
cst.stamp_escaper(escaper);
recovery.calendars.push(cst);
}
Ok((line, tail)) => {
loose.push(IcalItem::Prop(line));
rest = tail;
}
Err(error) => {
let (opaque, tail) = IcalLine::take_physical(rest);
loose.push(IcalItem::Opaque(Cow::Borrowed(opaque)));
recovery.problems.push(error);
rest = tail;
}
}
}
recovery.close_loose(&mut loose);
if let Some(last) = recovery.calendars.last_mut() {
last.take_trailing(rest);
} else {
let mut bare = Self::bare(Vec::new());
bare.take_trailing(rest);
recovery.calendars.push(bare);
}
recovery
}
fn take_component_recovering(
input: &'a [u8],
recovery: &mut IcalRecovery<'a>,
) -> (Self, &'a [u8]) {
let (begin, mut rest) = IcalLine::take(input).expect("a BEGIN line");
let name = begin.raw_value_str().into_owned();
let mut items: Vec<IcalItem<'a>> = Vec::new();
loop {
if is_blank(rest) {
recovery.problems.push(IcalParseError::MissingEnd(name));
return (
Self {
begin: Some(begin),
items,
end: None,
trailing: Cow::Borrowed(""),
},
rest,
);
}
match IcalLine::take(rest) {
Ok((line, tail)) => {
let line_name = line.name.get();
if line_name.eq_ignore_ascii_case("END") {
return (
Self {
begin: Some(begin),
items,
end: Some(line),
trailing: Cow::Borrowed(""),
},
tail,
);
}
if line_name.eq_ignore_ascii_case("BEGIN") {
let (child, next) = Self::take_component_recovering(rest, recovery);
items.push(IcalItem::Component(Box::new(child)));
rest = next;
continue;
}
items.push(IcalItem::Prop(line));
rest = tail;
}
Err(error) => {
let (opaque, tail) = IcalLine::take_physical(rest);
items.push(IcalItem::Opaque(Cow::Borrowed(opaque)));
recovery.problems.push(error);
rest = tail;
}
}
}
}
fn bare(items: Vec<IcalItem<'a>>) -> Self {
Self {
begin: None,
items,
end: None,
trailing: Cow::Borrowed(""),
}
}
fn take_trailing(&mut self, rest: &'a [u8]) -> &'a [u8] {
if !is_blank(rest) {
return rest;
}
self.trailing = Cow::Borrowed(str::from_utf8(rest).unwrap_or(""));
b""
}
fn take_component(input: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
let (begin, mut rest) = IcalLine::take(input)?;
if !begin.name.get().eq_ignore_ascii_case("BEGIN") {
return Err(IcalParseError::ExpectedBegin(begin.name.get().to_string()));
}
let mut items: Vec<IcalItem<'a>> = Vec::new();
loop {
if rest.is_empty() {
return Err(IcalParseError::MissingEnd(
begin.raw_value_str().into_owned(),
));
}
let (line, tail) = IcalLine::take(rest)?;
let name = line.name.get();
if name.eq_ignore_ascii_case("END") {
return Ok((
Self {
begin: Some(begin),
items,
end: Some(line),
trailing: Cow::Borrowed(""),
},
tail,
));
}
if name.eq_ignore_ascii_case("BEGIN") {
let (child, next) = Self::take_component(rest)?;
items.push(IcalItem::Component(Box::new(child)));
rest = next;
continue;
}
items.push(IcalItem::Prop(line));
rest = tail;
}
}
fn stamp_escaper(&mut self, escaper: Escaper) {
for item in &mut self.items {
match item {
IcalItem::Prop(line) => line.value.escaper = escaper,
IcalItem::Component(child) => child.stamp_escaper(escaper),
IcalItem::Opaque(_) => {}
}
}
}
fn version_str(&self) -> Cow<'_, str> {
self.items
.iter()
.find_map(|item| match item {
IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case("VERSION") => {
Some(line.raw_value_str())
}
_ => None,
})
.unwrap_or(Cow::Borrowed(""))
}
pub fn version(&self) -> IcalVersion {
self.version_str().parse().unwrap_or(IcalVersion::V2_0)
}
pub fn push(&mut self, prop: IcalProp<'a>) -> &mut Self {
let escaper = Escaper::for_version_str(&self.version_str());
self.items.push(IcalItem::Prop(prop.encode(escaper)));
self
}
pub fn push_component(&mut self, component: IcalCst<'a>) -> &mut Self {
self.items.push(IcalItem::Component(Box::new(component)));
self
}
pub fn remove<L: IcalPropLens>(&mut self) -> &mut Self {
self.items.retain(|item| match item {
IcalItem::Prop(line) => !line.name.get().eq_ignore_ascii_case(&L::KIND),
IcalItem::Component(_) => true,
IcalItem::Opaque(_) => true,
});
self
}
pub fn prop<L: IcalPropLens>(&self) -> Option<L::Target<'_>> {
let version = self.version();
self.items.iter().find_map(|item| match item {
IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case(&L::KIND) => {
Some(L::decode(line, version))
}
_ => None,
})
}
pub fn prop_mut<L: IcalPropLens>(&mut self) -> Option<L::Cursor<'_, 'a>> {
self.items.iter_mut().find_map(|item| match item {
IcalItem::Prop(line) if line.name.get().eq_ignore_ascii_case(&L::KIND) => {
Some(L::cursor(line))
}
_ => None,
})
}
pub fn component<C: IcalComponentLens>(&self) -> Option<&IcalCst<'a>> {
self.items.iter().find_map(|item| match item {
IcalItem::Component(child) if child.is_kind::<C>() => Some(&**child),
_ => None,
})
}
pub fn component_mut<C: IcalComponentLens>(&mut self) -> Option<&mut IcalCst<'a>> {
self.items.iter_mut().find_map(|item| match item {
IcalItem::Component(child) if child.is_kind::<C>() => Some(&mut **child),
_ => None,
})
}
pub fn components<C: IcalComponentLens>(&self) -> impl Iterator<Item = &IcalCst<'a>> {
self.items.iter().filter_map(|item| match item {
IcalItem::Component(child) if child.is_kind::<C>() => Some(&**child),
_ => None,
})
}
fn is_kind<C: IcalComponentLens>(&self) -> bool {
self.begin
.as_ref()
.map(|begin| begin.raw_value_str().eq_ignore_ascii_case(&C::KIND))
.unwrap_or(false)
}
pub(crate) fn component_name(&self) -> Cow<'_, str> {
self.begin
.as_ref()
.map(|begin| begin.raw_value_str())
.unwrap_or(Cow::Borrowed(""))
}
pub fn into_static(self) -> IcalCst<'static> {
IcalCst {
begin: self.begin.map(IcalLine::into_static),
items: self
.items
.into_iter()
.map(|item| match item {
IcalItem::Prop(line) => IcalItem::Prop(line.into_static()),
IcalItem::Component(child) => {
IcalItem::Component(Box::new(child.into_static()))
}
IcalItem::Opaque(bytes) => IcalItem::Opaque(Cow::Owned(bytes.into_owned())),
})
.collect(),
end: self.end.map(IcalLine::into_static),
trailing: Cow::Owned(self.trailing.into_owned()),
}
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
self.write_bytes(&mut out);
out
}
fn write_bytes(&self, out: &mut Vec<u8>) {
if let Some(begin) = &self.begin {
begin.write_bytes(out);
}
for item in &self.items {
match item {
IcalItem::Prop(line) => line.write_bytes(out),
IcalItem::Opaque(bytes) => out.extend_from_slice(bytes),
IcalItem::Component(child) => child.write_bytes(out),
}
}
if let Some(end) = &self.end {
end.write_bytes(out);
}
out.extend_from_slice(self.trailing.as_bytes());
}
}
#[derive(Clone, Debug, Default)]
pub struct IcalRecovery<'a> {
pub calendars: Vec<IcalCst<'a>>,
pub problems: Vec<IcalParseError>,
}
impl<'a> IcalRecovery<'a> {
pub fn is_clean(&self) -> bool {
self.problems.is_empty()
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
for cst in &self.calendars {
cst.write_bytes(&mut out);
}
out
}
fn close_loose(&mut self, loose: &mut Vec<IcalItem<'a>>) {
if loose.is_empty() {
return;
}
self.calendars.push(IcalCst::bare(core::mem::take(loose)));
}
}
fn is_blank(bytes: &[u8]) -> bool {
bytes.iter().all(|byte| matches!(byte, b'\r' | b'\n'))
}
impl fmt::Display for IcalCst<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(begin) = &self.begin {
write!(f, "{begin}")?;
}
for item in &self.items {
match item {
IcalItem::Prop(line) => write!(f, "{line}")?,
IcalItem::Opaque(bytes) => f.write_str(&String::from_utf8_lossy(bytes))?,
IcalItem::Component(child) => write!(f, "{child}")?,
}
}
if let Some(end) = &self.end {
write!(f, "{end}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use alloc::{
string::{String, ToString},
vec::Vec,
};
use crate::tree::{
component::vevent::VEVENT,
cst::IcalCst,
error::IcalParseError,
prop::{prodid::PRODID, summary::SUMMARY},
};
const CAL: &str = concat!(
"BEGIN:VCALENDAR\r\n",
"VERSION:2.0\r\n",
"PRODID:-//Example//EN\r\n",
"BEGIN:VEVENT\r\n",
"UID:1\r\n",
"DTSTAMP:20260101T000000Z\r\n",
"SUMMARY:Lunch\r\n",
"BEGIN:VALARM\r\n",
"ACTION:DISPLAY\r\n",
"TRIGGER:-PT15M\r\n",
"END:VALARM\r\n",
"END:VEVENT\r\n",
"END:VCALENDAR\r\n",
);
#[test]
fn round_trips_a_nested_calendar_byte_for_byte() {
let cst = IcalCst::parse(CAL).unwrap();
assert_eq!(cst.to_string(), CAL);
}
#[test]
fn reads_a_nested_property_through_component_and_prop_lenses() {
let cst = IcalCst::parse(CAL).unwrap();
let event = cst.component::<VEVENT>().expect("a VEVENT");
assert_eq!(&*event.prop::<SUMMARY>().unwrap().0, "Lunch");
}
#[test]
fn edits_a_nested_property_leaving_every_other_byte_intact() {
let mut cst = IcalCst::parse(CAL).unwrap();
cst.component_mut::<VEVENT>()
.unwrap()
.prop_mut::<SUMMARY>()
.unwrap()
.set_text("Dinner");
assert_eq!(
cst.to_string(),
CAL.replace("SUMMARY:Lunch", "SUMMARY:Dinner")
);
}
#[test]
fn reports_the_version() {
let cst = IcalCst::parse(CAL).unwrap();
assert_eq!(cst.version(), crate::version::IcalVersion::V2_0);
}
#[test]
fn round_trips_a_folded_calendar_byte_for_byte() {
let raw = concat!(
"BEGIN:VCALENDAR\r\n",
"VERSION:2.0\r\n",
"PRODID:-//Example//EN\r\n",
"\r\n",
"BEGIN:VEVENT\r\n",
"UID:1\r\n",
"DTSTAMP:20260101T000000Z\r\n",
"DESCRIPTION:a very long description that an exporter would fold at s\r\n",
" ome column\r\n",
"END:VEVENT\r\n",
"END:VCALENDAR\r\n",
"\r\n",
);
let cst = IcalCst::parse(raw).unwrap();
assert_eq!(String::from_utf8(cst.to_bytes()).unwrap(), raw);
}
#[test]
fn round_trips_a_leading_blank_line() {
let raw = "\r\nBEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
let cst = IcalCst::parse(raw).unwrap();
assert_eq!(String::from_utf8(cst.to_bytes()).unwrap(), raw);
}
#[test]
fn round_trips_a_whole_multi_calendar_file() {
let raw = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
"\r\n",
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n",
);
let mut out = Vec::new();
for cst in IcalCst::parse_many(raw) {
out.extend_from_slice(&cst.unwrap().to_bytes());
}
assert_eq!(String::from_utf8(out).unwrap(), raw);
}
#[test]
fn recovers_a_line_with_no_colon() {
let raw = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n",
"this line has no colon\r\n",
"PRODID:-//Example//EN\r\nEND:VCALENDAR\r\n",
);
assert!(IcalCst::parse(raw).is_err());
let recovery = IcalCst::parse_recovering(raw);
assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), raw);
assert_eq!(recovery.calendars.len(), 1);
assert!(matches!(
recovery.problems.as_slice(),
[IcalParseError::MissingPropertyColon(_)]
));
let cal = &recovery.calendars[0];
assert_eq!(&*cal.prop::<PRODID>().unwrap().0, "-//Example//EN");
}
#[test]
fn recovers_a_component_with_no_end() {
let raw = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\n";
assert!(IcalCst::parse(raw).is_err());
let recovery = IcalCst::parse_recovering(raw);
assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), raw);
assert_eq!(
recovery.problems,
[
IcalParseError::MissingEnd("VEVENT".into()),
IcalParseError::MissingEnd("VCALENDAR".into()),
]
);
assert!(recovery.calendars[0].component::<VEVENT>().is_some());
}
#[test]
fn reports_nothing_for_a_calendar_the_strict_parser_accepts() {
let recovery = IcalCst::parse_recovering(CAL);
assert!(recovery.is_clean());
assert_eq!(String::from_utf8(recovery.to_bytes()).unwrap(), CAL);
}
#[test]
fn refolds_nothing_once_a_value_is_edited() {
let raw = concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n",
"SUMMARY:a summary long enough to have been fol\r\n ded by its exporter\r\n",
"END:VEVENT\r\nEND:VCALENDAR\r\n",
);
let mut cst = IcalCst::parse(raw).unwrap();
cst.component_mut::<VEVENT>()
.unwrap()
.prop_mut::<SUMMARY>()
.unwrap()
.set_text("Dinner");
assert_eq!(
String::from_utf8(cst.to_bytes()).unwrap(),
concat!(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\n",
"SUMMARY:Dinner\r\n",
"END:VEVENT\r\nEND:VCALENDAR\r\n",
)
);
}
}