use core::{fmt, str};
use alloc::{borrow::Cow, string::String, vec, vec::Vec};
use crate::tree::{
codec::mode::VcardEscaper,
error::VcardParseError,
leaf::{VcardLeaf, VcardValueLeaf},
param::{lens::VcardParamLens, node::VcardParamNode},
value::node::VcardValueNode,
};
#[derive(Clone, Debug)]
pub struct VcardLine<'a> {
pub name: VcardLeaf<'a>,
pub params: Vec<VcardParamNode<'a>>,
pub value: VcardValueNode<'a>,
pub eol: VcardLeaf<'a>,
}
impl<'a> VcardLine<'a> {
pub fn text(name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
Self {
name: VcardLeaf(name.into()),
params: Vec::new(),
value: VcardValueNode::from_components(
vec![vec![VcardValueLeaf::from(value.into())]],
VcardEscaper::Modern,
),
eol: VcardLeaf(Cow::Borrowed("\r\n")),
}
}
pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), VcardParseError> {
let mut head = rest;
let (first, eol, mut tail) = loop {
if head.is_empty() {
return Err(VcardParseError::MissingCrlf(lossy(rest)));
}
let (content, eol, next) = physical_line(head);
if content.is_empty() {
head = next;
continue;
}
break (content, eol, next);
};
let first = strip_leading_wsp(first);
if first.ends_with(b"=") && head_is_quoted_printable(first) {
let mut logical = Vec::from(&first[..first.len() - 1]);
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),
None => {
logical.extend_from_slice(continuation);
break;
}
}
if tail.is_empty() {
break;
}
}
let mut line = Self::parse(&logical, b"")?.into_static();
line.eol = eol_leaf(last_eol);
return Ok((line, tail));
}
if !starts_with_wsp(tail) {
return Ok((Self::parse(first, eol)?, 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..]);
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);
Ok((line, tail))
}
pub(crate) fn into_static(self) -> VcardLine<'static> {
VcardLine {
name: self.name.into_static(),
params: self
.params
.into_iter()
.map(VcardParamNode::into_static)
.collect(),
value: self.value.into_static(),
eol: self.eol.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>) {
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);
out.extend_from_slice(self.eol.get().as_bytes());
}
pub fn param<P: VcardParamLens>(&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: VcardParamLens>(&mut self) -> Option<&mut VcardParamNode<'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<VcardLine<'b>, VcardParseError> {
let Some(colon) = memchr::memchr(b':', content) else {
return Err(VcardParseError::MissingPropertyColon(lossy(content)));
};
let head = str::from_utf8(&content[..colon])
.map_err(|_| VcardParseError::NonUtf8Header(lossy(&content[..colon])))?;
let (name, params) = split_head(head);
let mut value = &content[colon + 1..];
if head_is_quoted_printable(content) {
while value.last() == Some(&b'=') {
value = &value[..value.len() - 1];
}
}
Ok(VcardLine {
name: VcardLeaf::from(name),
params,
value: VcardValueNode::parse(value),
eol: VcardLeaf::from(str::from_utf8(eol).unwrap_or("")),
})
}
}
impl fmt::Display for VcardLine<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name.get())?;
for param in &self.params {
write!(f, ";{param}")?;
}
write!(f, ":{}{}", self.value, self.eol.get())
}
}
fn split_head(head: &str) -> (&str, Vec<VcardParamNode<'_>>) {
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(VcardParamNode::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]) -> VcardLeaf<'static> {
VcardLeaf::from(String::from_utf8_lossy(bytes).into_owned())
}
fn lossy(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes).into_owned()
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;
use crate::tree::line::VcardLine;
#[test]
fn takes_one_line_and_leaves_the_rest() {
let (line, rest) = VcardLine::take(b"FN:John\r\nEND:VCARD\r\n").unwrap();
assert_eq!(line.name.get(), "FN");
assert_eq!(line.to_string(), "FN:John\r\n");
assert_eq!(rest, b"END:VCARD\r\n");
}
#[test]
fn splits_parameters_off_the_head_then_round_trips() {
let (line, _) = VcardLine::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, _) = VcardLine::take(b"FN:John\n").unwrap();
assert_eq!(line.to_string(), "FN:John\n");
}
#[test]
fn unfolds_space_and_tab_continuations() {
let (line, rest) = VcardLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCARD\r\n").unwrap();
assert_eq!(line.name.get(), "NOTE");
assert_eq!(line.raw_value_str(), "foobarbaz");
assert_eq!(rest, b"END:VCARD\r\n");
}
#[test]
fn serializes_an_unfolded_line() {
let (line, _) = VcardLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
assert_eq!(line.to_string(), "NOTE:foobar\r\n");
}
#[test]
fn keeps_whitespace_beyond_the_single_fold_indicator() {
let (line, _) = VcardLine::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) = VcardLine::take(b"\r\n\r\nFN:John\r\nEND:VCARD\r\n").unwrap();
assert_eq!(line.name.get(), "FN");
assert_eq!(rest, b"END:VCARD\r\n");
}
#[test]
fn tolerates_a_missing_final_line_break() {
let (line, rest) = VcardLine::take(b"END:VCARD").unwrap();
assert_eq!(line.name.get(), "END");
assert_eq!(line.to_string(), "END:VCARD");
assert_eq!(rest, b"");
}
#[test]
fn joins_a_quoted_printable_soft_broken_line() {
let (line, _) =
VcardLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCARD\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!(VcardLine::take(b"").is_err());
assert!(VcardLine::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!(VcardLine::take(&raw).is_err());
}
#[test]
fn finds_a_parameter_mutably() {
use crate::tree::param::r#type::TYPE;
let (mut line, _) = VcardLine::take(b"TEL;TYPE=home:123\r\n").unwrap();
assert!(line.param_mut::<TYPE>().is_some());
}
#[test]
fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
assert!(VcardLine::take(b"abc=\r\n").is_err());
}
#[test]
fn quoted_printable_join_stops_at_an_empty_tail() {
let (line, rest) = VcardLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
assert_eq!(line.raw_value_str(), "ab");
assert_eq!(rest, b"");
}
}