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,
wire::VcardWire,
};
#[derive(Clone, Debug)]
pub struct VcardLine<'a> {
pub name: VcardLeaf<'a>,
pub params: Vec<VcardParamNode<'a>>,
pub value: VcardValueNode<'a>,
pub eol: VcardLeaf<'a>,
pub wire: VcardWire<'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::V4_0,
),
eol: VcardLeaf(Cow::Borrowed("\r\n")),
wire: VcardWire::default(),
}
}
pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), VcardParseError> {
let mut wire = VcardWire::default();
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);
};
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) => {
push_content(&mut logical, &mut wire, head);
if tail.is_empty() {
wire.skipped(logical.len(), "=");
break;
}
wire.soft(logical.len(), is_crlf(eol));
}
None => {
push_content(&mut logical, &mut wire, 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]);
push_content(&mut logical, &mut wire, 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(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(),
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: 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) = value_colon(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..];
let mut wire = VcardWire::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(VcardLine {
name: VcardLeaf::from(name),
params,
value: VcardValueNode::parse(value),
eol: VcardLeaf::from(str::from_utf8(eol).unwrap_or("")),
wire,
})
}
}
impl fmt::Display for VcardLine<'_> {
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 push_content<'a>(logical: &mut Vec<u8>, wire: &mut VcardWire<'a>, content: &'a [u8]) {
let kept = if logical.is_empty() {
strip_leading_wsp(content)
} else {
content
};
if kept.len() < content.len() {
wire.skipped(logical.len(), ascii(&content[..content.len() - kept.len()]));
}
logical.extend_from_slice(kept);
}
fn split_head(head: &str) -> (&str, Vec<VcardParamNode<'_>>) {
let (name, mut rest) = match param_semicolon(head) {
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 param_semicolon(after) {
Some(semi) => (&after[..semi], &after[semi..]),
None => (after, ""),
};
params.push(VcardParamNode::parse(param));
rest = tail;
}
(name, params)
}
fn value_colon(content: &[u8]) -> Option<usize> {
let mut quoted = false;
for (i, &byte) in content.iter().enumerate() {
match byte {
b'"' => quoted = !quoted,
b':' if !quoted => return Some(i),
_ => {}
}
}
memchr::memchr(b':', content)
}
fn param_semicolon(head: &str) -> Option<usize> {
let mut quoted = false;
for (i, byte) in head.bytes().enumerate() {
match byte {
b'"' => quoted = !quoted,
b';' if !quoted => return Some(i),
_ => {}
}
}
None
}
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 value_colon(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 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::VcardLine, value::node::VcardValueNode};
#[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 keeps_a_quoted_parameter_value_whole() {
let raw = b"ADR;GEO=\"geo:12.3457,78.910\";TYPE=work:;;123 Main Street\r\n";
let (line, _) = VcardLine::take(raw).unwrap();
assert_eq!(line.params.len(), 2);
assert_eq!(line.params[0].name.get(), "GEO");
assert_eq!(line.params[0].values[0].get(), "\"geo:12.3457,78.910\"");
assert_eq!(line.params[1].name.get(), "TYPE");
assert_eq!(line.value.component_count(), 3);
assert_eq!(line.to_string(), str::from_utf8(raw).unwrap());
}
#[test]
fn an_unbalanced_quote_still_parses() {
let (line, _) = VcardLine::take(b"TEL;TYPE=\"work:+1\r\n").unwrap();
assert_eq!(line.name.get(), "TEL");
assert_eq!(line.to_string(), "TEL;TYPE=\"work:+1\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_a_folded_line_back_folded() {
let (line, _) = VcardLine::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, _) = VcardLine::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, _) = VcardLine::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, _) = VcardLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
line.value = VcardValueNode::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, _) = VcardLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
line.value = VcardValueNode::parse(b"BARFOO");
assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
}
#[test]
fn a_continuation_of_a_blank_line_does_not_keep_its_whitespace() {
let (line, _) = VcardLine::take(b" \r\n A:b\r\n").unwrap();
assert_eq!(line.name.get(), "A");
assert_eq!(line.to_string(), " \r\n A:b\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 raw = b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\n";
let (line, _) = VcardLine::take(raw).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");
assert_eq!(line.to_string(), str::from_utf8(raw).unwrap());
}
#[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 raw = b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n";
let (line, rest) = VcardLine::take(raw).unwrap();
assert_eq!(line.raw_value_str(), "ab");
assert_eq!(rest, b"");
assert_eq!(line.to_string(), str::from_utf8(raw).unwrap());
}
}