1use core::{fmt, str};
21
22use alloc::{borrow::Cow, string::String, vec, vec::Vec};
23
24use crate::tree::{
25 codec::mode::Escaper,
26 error::IcalParseError,
27 leaf::{IcalLeaf, IcalValueLeaf},
28 param::{lens::IcalParamLens, node::IcalParamNode},
29 value::node::IcalValueNode,
30 wire::IcalWire,
31};
32
33#[derive(Clone, Debug)]
43pub struct IcalLine<'a> {
44 pub name: IcalLeaf<'a>,
46 pub params: Vec<IcalParamNode<'a>>,
48 pub value: IcalValueNode<'a>,
50 pub eol: IcalLeaf<'a>,
52 pub wire: IcalWire<'a>,
56}
57
58impl<'a> IcalLine<'a> {
59 pub fn text(name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
62 Self {
63 name: IcalLeaf(name.into()),
64 params: Vec::new(),
65 value: IcalValueNode::from_components(
66 vec![vec![IcalValueLeaf::from(value.into())]],
67 Escaper::Modern,
68 ),
69 eol: IcalLeaf(Cow::Borrowed("\r\n")),
70 wire: IcalWire::default(),
71 }
72 }
73
74 pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
81 let mut wire = IcalWire::default();
84
85 let mut head = rest;
87 let (first, eol, mut tail) = loop {
88 if head.is_empty() {
89 return Err(IcalParseError::MissingCrlf(lossy(rest)));
90 }
91 let (content, eol, next) = physical_line(head);
92 if content.is_empty() {
93 head = next;
94 continue;
95 }
96 break (content, eol, next);
97 };
98
99 if head.len() < rest.len() {
100 wire.skipped(0, ascii(&rest[..rest.len() - head.len()]));
101 }
102
103 let indented = first;
109 let first = strip_leading_wsp(first);
110
111 if first.len() < indented.len() {
112 wire.skipped(0, ascii(&indented[..indented.len() - first.len()]));
113 }
114
115 if first.ends_with(b"=") && head_is_quoted_printable(first) {
120 let mut logical = Vec::from(&first[..first.len() - 1]);
121 wire.soft(logical.len(), is_crlf(eol));
122
123 let mut last_eol;
124 loop {
125 let (continuation, eol, next) = physical_line(tail);
126 last_eol = eol;
127 tail = next;
128 match continuation.strip_suffix(b"=") {
129 Some(head) => {
130 logical.extend_from_slice(head);
131 if tail.is_empty() {
132 wire.skipped(logical.len(), "=");
137 break;
138 }
139 wire.soft(logical.len(), is_crlf(eol));
140 }
141 None => {
142 logical.extend_from_slice(continuation);
143 break;
144 }
145 }
146 }
147
148 let mut line = Self::parse(&logical, b"")?.into_static();
149 line.eol = eol_leaf(last_eol);
150 line.wire.prepend(wire.into_static());
151 return Ok((line, tail));
152 }
153
154 if !starts_with_wsp(tail) {
155 let mut line = Self::parse(first, eol)?;
156 line.wire.prepend(wire);
157 return Ok((line, tail));
158 }
159
160 let mut logical = Vec::from(first);
161 let mut last_eol = eol;
162
163 while starts_with_wsp(tail) {
164 let (continuation, eol, next) = physical_line(&tail[1..]);
165 wire.fold(logical.len(), is_crlf(last_eol), tail[0]);
166 logical.extend_from_slice(continuation);
167 last_eol = eol;
168 tail = next;
169 }
170
171 let mut line = Self::parse(&logical, b"")?.into_static();
172 line.eol = eol_leaf(last_eol);
173 line.wire.prepend(wire.into_static());
174
175 Ok((line, tail))
176 }
177
178 pub fn take_physical(rest: &'a [u8]) -> (&'a [u8], &'a [u8]) {
184 let (content, eol, tail) = physical_line(rest);
185 (&rest[..content.len() + eol.len()], tail)
186 }
187
188 pub(crate) fn into_static(self) -> IcalLine<'static> {
190 IcalLine {
191 name: self.name.into_static(),
192 params: self
193 .params
194 .into_iter()
195 .map(IcalParamNode::into_static)
196 .collect(),
197 value: self.value.into_static(),
198 eol: self.eol.into_static(),
199 wire: self.wire.into_static(),
200 }
201 }
202
203 pub fn raw_value(&self) -> &[u8] {
205 self.value.first_value_bytes()
206 }
207
208 pub fn raw_value_str(&self) -> Cow<'_, str> {
211 String::from_utf8_lossy(self.value.first_value_bytes())
212 }
213
214 pub(crate) fn write_bytes(&self, out: &mut Vec<u8>) {
217 if self.wire.is_empty() {
218 self.write_logical(out);
219 } else {
220 let mut logical = Vec::new();
221 self.write_logical(&mut logical);
222 self.wire.write_bytes(&logical, out);
223 }
224
225 out.extend_from_slice(self.eol.get().as_bytes());
226 }
227
228 fn write_logical(&self, out: &mut Vec<u8>) {
232 out.extend_from_slice(self.name.get().as_bytes());
233
234 for param in &self.params {
235 out.push(b';');
236 param.write_bytes(out);
237 }
238
239 out.push(b':');
240 self.value.write_bytes(out);
241 }
242
243 pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
245 self.params
246 .iter()
247 .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
248 .map(|param| P::decode(param))
249 }
250
251 pub fn param_mut<P: IcalParamLens>(&mut self) -> Option<&mut IcalParamNode<'a>> {
253 self.params
254 .iter_mut()
255 .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
256 }
257
258 fn parse<'b>(content: &'b [u8], eol: &'b [u8]) -> Result<IcalLine<'b>, IcalParseError> {
263 let Some(colon) = value_colon(content) else {
264 return Err(IcalParseError::MissingPropertyColon(lossy(content)));
265 };
266
267 let head = str::from_utf8(&content[..colon])
268 .map_err(|_| IcalParseError::NonUtf8Header(lossy(&content[..colon])))?;
269 let (name, params) = split_head(head);
270
271 let mut value = &content[colon + 1..];
272 let mut wire = IcalWire::default();
273
274 if head_is_quoted_printable(content) {
282 let full = value.len();
283 while value.last() == Some(&b'=') {
284 value = &value[..value.len() - 1];
285 }
286 if value.len() < full {
287 let end = colon + 1 + value.len();
288 wire.skipped(end, ascii(&content[end..colon + 1 + full]));
289 }
290 }
291
292 wire.seal(colon + 1 + value.len());
293
294 Ok(IcalLine {
295 name: IcalLeaf::from(name),
296 params,
297 value: IcalValueNode::parse(value),
298 eol: IcalLeaf::from(str::from_utf8(eol).unwrap_or("")),
299 wire,
300 })
301 }
302}
303
304impl fmt::Display for IcalLine<'_> {
305 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 if self.wire.is_empty() {
309 f.write_str(self.name.get())?;
310
311 for param in &self.params {
312 write!(f, ";{param}")?;
313 }
314
315 return write!(f, ":{}{}", self.value, self.eol.get());
316 }
317
318 let mut bytes = Vec::new();
319 self.write_bytes(&mut bytes);
320 f.write_str(&String::from_utf8_lossy(&bytes))
321 }
322}
323
324fn split_head(head: &str) -> (&str, Vec<IcalParamNode<'_>>) {
326 let (name, mut rest) = match param_semicolon(head) {
327 Some(semi) => (&head[..semi], &head[semi..]),
328 None => return (head, Vec::new()),
329 };
330
331 let mut params = Vec::new();
332
333 while let Some(after) = rest.strip_prefix(';') {
334 let (param, tail) = match param_semicolon(after) {
335 Some(semi) => (&after[..semi], &after[semi..]),
336 None => (after, ""),
337 };
338
339 params.push(IcalParamNode::parse(param));
340 rest = tail;
341 }
342
343 (name, params)
344}
345
346fn value_colon(content: &[u8]) -> Option<usize> {
353 let mut quoted = false;
354
355 for (i, &byte) in content.iter().enumerate() {
356 match byte {
357 b'"' => quoted = !quoted,
358 b':' if !quoted => return Some(i),
359 _ => {}
360 }
361 }
362
363 memchr::memchr(b':', content)
364}
365
366fn param_semicolon(head: &str) -> Option<usize> {
369 let mut quoted = false;
370
371 for (i, byte) in head.bytes().enumerate() {
372 match byte {
373 b'"' => quoted = !quoted,
374 b';' if !quoted => return Some(i),
375 _ => {}
376 }
377 }
378
379 None
380}
381
382fn physical_line(rest: &[u8]) -> (&[u8], &[u8], &[u8]) {
386 let Some(lf) = memchr::memchr(b'\n', rest) else {
387 return (rest, b"", b"");
388 };
389
390 let tail = &rest[lf + 1..];
391
392 let (content, eol) = if lf > 0 && rest[lf - 1] == b'\r' {
393 (&rest[..lf - 1], &rest[lf - 1..lf + 1])
394 } else {
395 (&rest[..lf], &rest[lf..lf + 1])
396 };
397
398 (content, eol, tail)
399}
400
401fn starts_with_wsp(rest: &[u8]) -> bool {
403 matches!(rest.first(), Some(b' ' | b'\t'))
404}
405
406fn strip_leading_wsp(mut bytes: &[u8]) -> &[u8] {
410 while matches!(bytes.first(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
411 bytes = &bytes[1..];
412 }
413 bytes
414}
415
416fn head_is_quoted_printable(line: &[u8]) -> bool {
419 let head = match value_colon(line) {
420 Some(colon) => &line[..colon],
421 None => return false,
422 };
423
424 head.split(|&b| b == b';').any(|token| {
425 token.eq_ignore_ascii_case(b"QUOTED-PRINTABLE")
426 || token.eq_ignore_ascii_case(b"ENCODING=QUOTED-PRINTABLE")
427 })
428}
429
430fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
432 IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
433}
434
435fn is_crlf(eol: &[u8]) -> bool {
437 eol.starts_with(b"\r")
438}
439
440fn ascii(bytes: &[u8]) -> &str {
444 str::from_utf8(bytes).unwrap_or("")
445}
446
447fn lossy(bytes: &[u8]) -> String {
449 String::from_utf8_lossy(bytes).into_owned()
450}
451
452#[cfg(test)]
453mod tests {
454 use alloc::string::ToString;
455
456 use crate::tree::{line::IcalLine, value::node::IcalValueNode};
457
458 #[test]
459 fn takes_one_line_and_leaves_the_rest() {
460 let (line, rest) = IcalLine::take(b"FN:John\r\nEND:VCALENDAR\r\n").unwrap();
461 assert_eq!(line.name.get(), "FN");
462 assert_eq!(line.to_string(), "FN:John\r\n");
463 assert_eq!(rest, b"END:VCALENDAR\r\n");
464 }
465
466 #[test]
467 fn splits_parameters_off_the_head_then_round_trips() {
468 let (line, _) = IcalLine::take(b"TEL;TYPE=work,home:123\r\n").unwrap();
469 assert_eq!(line.params.len(), 1);
470 assert_eq!(line.to_string(), "TEL;TYPE=work,home:123\r\n");
471 }
472
473 #[test]
474 fn keeps_a_quoted_parameter_value_whole() {
475 let raw = "DESCRIPTION;ALTREP=\"cid:part1.0001@example.org\";LANGUAGE=en:Meeting notes\r\n";
478 let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
479
480 assert_eq!(line.name.get(), "DESCRIPTION");
481 assert_eq!(line.params.len(), 2);
482 assert_eq!(line.params[0].name.get(), "ALTREP");
483 assert_eq!(
484 line.params[0].values[0].get(),
485 "\"cid:part1.0001@example.org\""
486 );
487 assert_eq!(line.params[1].name.get(), "LANGUAGE");
488 assert_eq!(line.raw_value_str(), "Meeting notes");
489 assert_eq!(line.to_string(), raw);
490 }
491
492 #[test]
493 fn keeps_a_quoted_semicolon_out_of_the_parameter_split() {
494 let raw = "ATTENDEE;DIR=\"ldap://host:389/cn=Ada;o=Example\":mailto:ada@example.com\r\n";
495 let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
496
497 assert_eq!(line.params.len(), 1);
498 assert_eq!(line.params[0].name.get(), "DIR");
499 assert_eq!(line.raw_value_str(), "mailto:ada@example.com");
500 assert_eq!(line.to_string(), raw);
501 }
502
503 #[test]
504 fn an_unbalanced_quote_still_parses() {
505 let raw = "ATTENDEE;CN=\"Ada:mailto:ada@example.com\r\n";
508 let (line, _) = IcalLine::take(raw.as_bytes()).unwrap();
509
510 assert_eq!(line.name.get(), "ATTENDEE");
511 assert_eq!(line.to_string(), raw);
512 }
513
514 #[test]
515 fn accepts_a_bare_lf_ending() {
516 let (line, _) = IcalLine::take(b"FN:John\n").unwrap();
517 assert_eq!(line.to_string(), "FN:John\n");
518 }
519
520 #[test]
521 fn unfolds_space_and_tab_continuations() {
522 let (line, rest) =
523 IcalLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCALENDAR\r\n").unwrap();
524 assert_eq!(line.name.get(), "NOTE");
525 assert_eq!(line.raw_value_str(), "foobarbaz");
526 assert_eq!(rest, b"END:VCALENDAR\r\n");
527 }
528
529 #[test]
530 fn serializes_a_folded_line_back_folded() {
531 let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
532 assert_eq!(line.raw_value_str(), "foobar");
533 assert_eq!(line.to_string(), "NOTE:foo\r\n bar\r\n");
534 }
535
536 #[test]
537 fn keeps_the_folding_whitespace_and_the_break_it_arrived_with() {
538 let (line, _) = IcalLine::take(b"NOTE:foo\n\tbar\r\n").unwrap();
539 assert_eq!(line.to_string(), "NOTE:foo\n\tbar\r\n");
540 }
541
542 #[test]
543 fn serializes_a_skipped_blank_line_back() {
544 let (line, _) = IcalLine::take(b"\r\n\r\nFN:John\r\n").unwrap();
545 assert_eq!(line.to_string(), "\r\n\r\nFN:John\r\n");
546 }
547
548 #[test]
549 fn drops_the_fold_points_once_the_value_is_edited() {
550 let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
553 line.value = IcalValueNode::parse(b"something else entirely");
554 assert_eq!(line.to_string(), "NOTE:something else entirely\r\n");
555 }
556
557 #[test]
558 fn keeps_the_fold_points_when_an_edit_keeps_the_length() {
559 let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
560 line.value = IcalValueNode::parse(b"BARFOO");
561 assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
562 }
563
564 #[test]
565 fn keeps_whitespace_beyond_the_single_fold_indicator() {
566 let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
567 assert_eq!(line.raw_value_str(), "foo bar");
568 }
569
570 #[test]
571 fn skips_blank_lines_before_the_next_line() {
572 let (line, rest) = IcalLine::take(b"\r\n\r\nFN:John\r\nEND:VCALENDAR\r\n").unwrap();
573 assert_eq!(line.name.get(), "FN");
574 assert_eq!(rest, b"END:VCALENDAR\r\n");
575 }
576
577 #[test]
578 fn tolerates_a_missing_final_line_break() {
579 let (line, rest) = IcalLine::take(b"END:VCALENDAR").unwrap();
580 assert_eq!(line.name.get(), "END");
581 assert_eq!(line.to_string(), "END:VCALENDAR");
582 assert_eq!(rest, b"");
583 }
584
585 #[test]
586 fn joins_a_quoted_printable_soft_broken_line() {
587 let (line, _) = IcalLine::take(
590 b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCALENDAR\r\n",
591 )
592 .unwrap();
593 assert_eq!(line.name.get(), "NOTE");
594 assert_eq!(line.raw_value_str(), "caf=C3=A9");
595 assert_eq!(line.raw_value(), b"caf=C3=A9");
596 }
597
598 #[test]
599 fn errors_when_there_is_no_content_line() {
600 assert!(IcalLine::take(b"").is_err());
601 assert!(IcalLine::take(b"\r\n\r\n").is_err());
602 }
603
604 #[test]
605 fn rejects_a_non_utf8_head() {
606 let mut raw = b"X-".to_vec();
607 raw.push(0xff);
608 raw.extend_from_slice(b":v\r\n");
609 assert!(IcalLine::take(&raw).is_err());
610 }
611
612 #[test]
613 fn finds_a_parameter_mutably() {
614 use crate::tree::param::language::LANGUAGE;
615
616 let (mut line, _) = IcalLine::take(b"SUMMARY;LANGUAGE=en:Lunch\r\n").unwrap();
617 assert!(line.param_mut::<LANGUAGE>().is_some());
618 }
619
620 #[test]
621 fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
622 assert!(IcalLine::take(b"abc=\r\n").is_err());
625 }
626
627 #[test]
628 fn quoted_printable_join_stops_at_an_empty_tail() {
629 let (line, rest) = IcalLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
633 assert_eq!(line.raw_value_str(), "ab");
634 assert_eq!(rest, b"");
635 }
636}