use core::{fmt, str};
use alloc::{borrow::Cow, string::String, vec, vec::Vec};
use crate::tree::{
codec::mode::Escaper,
error::IcalParseError,
leaf::{IcalLeaf, IcalValueLeaf},
param::{lens::IcalParamLens, node::IcalParamNode},
value::node::IcalValueNode,
wire::IcalWire,
};
#[derive(Clone, Debug)]
pub struct IcalLine<'a> {
pub name: IcalLeaf<'a>,
pub params: Vec<IcalParamNode<'a>>,
pub value: IcalValueNode<'a>,
pub eol: IcalLeaf<'a>,
pub wire: IcalWire<'a>,
}
impl<'a> IcalLine<'a> {
pub fn text(name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
Self {
name: IcalLeaf(name.into()),
params: Vec::new(),
value: IcalValueNode::from_components(
vec![vec![IcalValueLeaf::from(value.into())]],
Escaper::Modern,
),
eol: IcalLeaf(Cow::Borrowed("\r\n")),
wire: IcalWire::default(),
}
}
pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
let mut wire = IcalWire::default();
let mut head = rest;
let (first, eol, mut tail) = loop {
if head.is_empty() {
return Err(IcalParseError::MissingCrlf(lossy(rest)));
}
let (content, eol, next) = physical_line(head);
if content.is_empty() {
head = next;
continue;
}
break (content, eol, next);
};
if head.len() < rest.len() {
wire.skipped(0, ascii(&rest[..rest.len() - head.len()]));
}
let indented = first;
let first = strip_leading_wsp(first);
if first.len() < indented.len() {
wire.skipped(0, ascii(&indented[..indented.len() - first.len()]));
}
if first.ends_with(b"=") && head_is_quoted_printable(first) {
let mut logical = Vec::from(&first[..first.len() - 1]);
wire.soft(logical.len(), is_crlf(eol));
let mut last_eol;
loop {
let (continuation, eol, next) = physical_line(tail);
last_eol = eol;
tail = next;
match continuation.strip_suffix(b"=") {
Some(head) => {
logical.extend_from_slice(head);
if tail.is_empty() {
wire.skipped(logical.len(), "=");
break;
}
wire.soft(logical.len(), is_crlf(eol));
}
None => {
logical.extend_from_slice(continuation);
break;
}
}
}
let mut line = Self::parse(&logical, b"")?.into_static();
line.eol = eol_leaf(last_eol);
line.wire.prepend(wire.into_static());
return Ok((line, tail));
}
if !starts_with_wsp(tail) {
let mut line = Self::parse(first, eol)?;
line.wire.prepend(wire);
return Ok((line, tail));
}
let mut logical = Vec::from(first);
let mut last_eol = eol;
while starts_with_wsp(tail) {
let (continuation, eol, next) = physical_line(&tail[1..]);
wire.fold(logical.len(), is_crlf(last_eol), tail[0]);
logical.extend_from_slice(continuation);
last_eol = eol;
tail = next;
}
let mut line = Self::parse(&logical, b"")?.into_static();
line.eol = eol_leaf(last_eol);
line.wire.prepend(wire.into_static());
Ok((line, tail))
}
pub fn take_physical(rest: &'a [u8]) -> (&'a [u8], &'a [u8]) {
let (content, eol, tail) = physical_line(rest);
(&rest[..content.len() + eol.len()], tail)
}
pub(crate) fn into_static(self) -> IcalLine<'static> {
IcalLine {
name: self.name.into_static(),
params: self
.params
.into_iter()
.map(IcalParamNode::into_static)
.collect(),
value: self.value.into_static(),
eol: self.eol.into_static(),
wire: self.wire.into_static(),
}
}
pub fn raw_value(&self) -> &[u8] {
self.value.first_value_bytes()
}
pub fn raw_value_str(&self) -> Cow<'_, str> {
String::from_utf8_lossy(self.value.first_value_bytes())
}
pub(crate) fn write_bytes(&self, out: &mut Vec<u8>) {
if self.wire.is_empty() {
self.write_logical(out);
} else {
let mut logical = Vec::new();
self.write_logical(&mut logical);
self.wire.write_bytes(&logical, out);
}
out.extend_from_slice(self.eol.get().as_bytes());
}
fn write_logical(&self, out: &mut Vec<u8>) {
out.extend_from_slice(self.name.get().as_bytes());
for param in &self.params {
out.push(b';');
param.write_bytes(out);
}
out.push(b':');
self.value.write_bytes(out);
}
pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
self.params
.iter()
.find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
.map(|param| P::decode(param))
}
pub fn param_mut<P: IcalParamLens>(&mut self) -> Option<&mut IcalParamNode<'a>> {
self.params
.iter_mut()
.find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
}
fn parse<'b>(content: &'b [u8], eol: &'b [u8]) -> Result<IcalLine<'b>, IcalParseError> {
let Some(colon) = memchr::memchr(b':', content) else {
return Err(IcalParseError::MissingPropertyColon(lossy(content)));
};
let head = str::from_utf8(&content[..colon])
.map_err(|_| IcalParseError::NonUtf8Header(lossy(&content[..colon])))?;
let (name, params) = split_head(head);
let mut value = &content[colon + 1..];
let mut wire = IcalWire::default();
if head_is_quoted_printable(content) {
let full = value.len();
while value.last() == Some(&b'=') {
value = &value[..value.len() - 1];
}
if value.len() < full {
let end = colon + 1 + value.len();
wire.skipped(end, ascii(&content[end..colon + 1 + full]));
}
}
wire.seal(colon + 1 + value.len());
Ok(IcalLine {
name: IcalLeaf::from(name),
params,
value: IcalValueNode::parse(value),
eol: IcalLeaf::from(str::from_utf8(eol).unwrap_or("")),
wire,
})
}
}
impl fmt::Display for IcalLine<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.wire.is_empty() {
f.write_str(self.name.get())?;
for param in &self.params {
write!(f, ";{param}")?;
}
return write!(f, ":{}{}", self.value, self.eol.get());
}
let mut bytes = Vec::new();
self.write_bytes(&mut bytes);
f.write_str(&String::from_utf8_lossy(&bytes))
}
}
fn split_head(head: &str) -> (&str, Vec<IcalParamNode<'_>>) {
let (name, mut rest) = match head.find(';') {
Some(semi) => (&head[..semi], &head[semi..]),
None => return (head, Vec::new()),
};
let mut params = Vec::new();
while let Some(after) = rest.strip_prefix(';') {
let (param, tail) = match after.find(';') {
Some(semi) => (&after[..semi], &after[semi..]),
None => (after, ""),
};
params.push(IcalParamNode::parse(param));
rest = tail;
}
(name, params)
}
fn physical_line(rest: &[u8]) -> (&[u8], &[u8], &[u8]) {
let Some(lf) = memchr::memchr(b'\n', rest) else {
return (rest, b"", b"");
};
let tail = &rest[lf + 1..];
let (content, eol) = if lf > 0 && rest[lf - 1] == b'\r' {
(&rest[..lf - 1], &rest[lf - 1..lf + 1])
} else {
(&rest[..lf], &rest[lf..lf + 1])
};
(content, eol, tail)
}
fn starts_with_wsp(rest: &[u8]) -> bool {
matches!(rest.first(), Some(b' ' | b'\t'))
}
fn strip_leading_wsp(mut bytes: &[u8]) -> &[u8] {
while matches!(bytes.first(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
bytes = &bytes[1..];
}
bytes
}
fn head_is_quoted_printable(line: &[u8]) -> bool {
let head = match memchr::memchr(b':', line) {
Some(colon) => &line[..colon],
None => return false,
};
head.split(|&b| b == b';').any(|token| {
token.eq_ignore_ascii_case(b"QUOTED-PRINTABLE")
|| token.eq_ignore_ascii_case(b"ENCODING=QUOTED-PRINTABLE")
})
}
fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
}
fn is_crlf(eol: &[u8]) -> bool {
eol.starts_with(b"\r")
}
fn ascii(bytes: &[u8]) -> &str {
str::from_utf8(bytes).unwrap_or("")
}
fn lossy(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;
use crate::tree::{line::IcalLine, value::node::IcalValueNode};
#[test]
fn takes_one_line_and_leaves_the_rest() {
let (line, rest) = IcalLine::take(b"FN:John\r\nEND:VCALENDAR\r\n").unwrap();
assert_eq!(line.name.get(), "FN");
assert_eq!(line.to_string(), "FN:John\r\n");
assert_eq!(rest, b"END:VCALENDAR\r\n");
}
#[test]
fn splits_parameters_off_the_head_then_round_trips() {
let (line, _) = IcalLine::take(b"TEL;TYPE=work,home:123\r\n").unwrap();
assert_eq!(line.params.len(), 1);
assert_eq!(line.to_string(), "TEL;TYPE=work,home:123\r\n");
}
#[test]
fn accepts_a_bare_lf_ending() {
let (line, _) = IcalLine::take(b"FN:John\n").unwrap();
assert_eq!(line.to_string(), "FN:John\n");
}
#[test]
fn unfolds_space_and_tab_continuations() {
let (line, rest) =
IcalLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCALENDAR\r\n").unwrap();
assert_eq!(line.name.get(), "NOTE");
assert_eq!(line.raw_value_str(), "foobarbaz");
assert_eq!(rest, b"END:VCALENDAR\r\n");
}
#[test]
fn serializes_a_folded_line_back_folded() {
let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
assert_eq!(line.raw_value_str(), "foobar");
assert_eq!(line.to_string(), "NOTE:foo\r\n bar\r\n");
}
#[test]
fn keeps_the_folding_whitespace_and_the_break_it_arrived_with() {
let (line, _) = IcalLine::take(b"NOTE:foo\n\tbar\r\n").unwrap();
assert_eq!(line.to_string(), "NOTE:foo\n\tbar\r\n");
}
#[test]
fn serializes_a_skipped_blank_line_back() {
let (line, _) = IcalLine::take(b"\r\n\r\nFN:John\r\n").unwrap();
assert_eq!(line.to_string(), "\r\n\r\nFN:John\r\n");
}
#[test]
fn drops_the_fold_points_once_the_value_is_edited() {
let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
line.value = IcalValueNode::parse(b"something else entirely");
assert_eq!(line.to_string(), "NOTE:something else entirely\r\n");
}
#[test]
fn keeps_the_fold_points_when_an_edit_keeps_the_length() {
let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
line.value = IcalValueNode::parse(b"BARFOO");
assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
}
#[test]
fn keeps_whitespace_beyond_the_single_fold_indicator() {
let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
assert_eq!(line.raw_value_str(), "foo bar");
}
#[test]
fn skips_blank_lines_before_the_next_line() {
let (line, rest) = IcalLine::take(b"\r\n\r\nFN:John\r\nEND:VCALENDAR\r\n").unwrap();
assert_eq!(line.name.get(), "FN");
assert_eq!(rest, b"END:VCALENDAR\r\n");
}
#[test]
fn tolerates_a_missing_final_line_break() {
let (line, rest) = IcalLine::take(b"END:VCALENDAR").unwrap();
assert_eq!(line.name.get(), "END");
assert_eq!(line.to_string(), "END:VCALENDAR");
assert_eq!(rest, b"");
}
#[test]
fn joins_a_quoted_printable_soft_broken_line() {
let (line, _) = IcalLine::take(
b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCALENDAR\r\n",
)
.unwrap();
assert_eq!(line.name.get(), "NOTE");
assert_eq!(line.raw_value_str(), "caf=C3=A9");
assert_eq!(line.raw_value(), b"caf=C3=A9");
}
#[test]
fn errors_when_there_is_no_content_line() {
assert!(IcalLine::take(b"").is_err());
assert!(IcalLine::take(b"\r\n\r\n").is_err());
}
#[test]
fn rejects_a_non_utf8_head() {
let mut raw = b"X-".to_vec();
raw.push(0xff);
raw.extend_from_slice(b":v\r\n");
assert!(IcalLine::take(&raw).is_err());
}
#[test]
fn finds_a_parameter_mutably() {
use crate::tree::param::language::LANGUAGE;
let (mut line, _) = IcalLine::take(b"SUMMARY;LANGUAGE=en:Lunch\r\n").unwrap();
assert!(line.param_mut::<LANGUAGE>().is_some());
}
#[test]
fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
assert!(IcalLine::take(b"abc=\r\n").is_err());
}
#[test]
fn quoted_printable_join_stops_at_an_empty_tail() {
let (line, rest) = IcalLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
assert_eq!(line.raw_value_str(), "ab");
assert_eq!(rest, b"");
}
}