1use core::{fmt, str};
18
19use alloc::{borrow::Cow, string::String, vec, vec::Vec};
20
21use crate::tree::{
22 codec::mode::Escaper,
23 error::IcalParseError,
24 leaf::{IcalLeaf, IcalValueLeaf},
25 param::{lens::IcalParamLens, node::IcalParamNode},
26 value::node::IcalValueNode,
27 wire::IcalWire,
28};
29
30#[derive(Clone, Debug)]
39pub struct IcalLine<'a> {
40 pub name: IcalLeaf<'a>,
42 pub params: Vec<IcalParamNode<'a>>,
44 pub value: IcalValueNode<'a>,
46 pub eol: IcalLeaf<'a>,
48 pub wire: IcalWire<'a>,
52}
53
54impl<'a> IcalLine<'a> {
55 pub fn text(name: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
58 Self {
59 name: IcalLeaf(name.into()),
60 params: Vec::new(),
61 value: IcalValueNode::from_components(
62 vec![vec![IcalValueLeaf::from(value.into())]],
63 Escaper::Modern,
64 ),
65 eol: IcalLeaf(Cow::Borrowed("\r\n")),
66 wire: IcalWire::default(),
67 }
68 }
69
70 pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
76 let mut wire = IcalWire::default();
79
80 let mut head = rest;
82 let (first, eol, mut tail) = loop {
83 if head.is_empty() {
84 return Err(IcalParseError::MissingCrlf(lossy(rest)));
85 }
86 let (content, eol, next) = physical_line(head);
87 if content.is_empty() {
88 head = next;
89 continue;
90 }
91 break (content, eol, next);
92 };
93
94 if head.len() < rest.len() {
95 wire.skipped(0, ascii(&rest[..rest.len() - head.len()]));
96 }
97
98 let indented = first;
104 let first = strip_leading_wsp(first);
105
106 if first.len() < indented.len() {
107 wire.skipped(0, ascii(&indented[..indented.len() - first.len()]));
108 }
109
110 if first.ends_with(b"=") && head_is_quoted_printable(first) {
115 let mut logical = Vec::from(&first[..first.len() - 1]);
116 wire.soft(logical.len(), is_crlf(eol));
117
118 let mut last_eol;
119 loop {
120 let (continuation, eol, next) = physical_line(tail);
121 last_eol = eol;
122 tail = next;
123 match continuation.strip_suffix(b"=") {
124 Some(head) => {
125 logical.extend_from_slice(head);
126 if tail.is_empty() {
127 wire.skipped(logical.len(), "=");
132 break;
133 }
134 wire.soft(logical.len(), is_crlf(eol));
135 }
136 None => {
137 logical.extend_from_slice(continuation);
138 break;
139 }
140 }
141 }
142
143 let mut line = Self::parse(&logical, b"")?.into_static();
144 line.eol = eol_leaf(last_eol);
145 line.wire.prepend(wire.into_static());
146 return Ok((line, tail));
147 }
148
149 if !starts_with_wsp(tail) {
150 let mut line = Self::parse(first, eol)?;
151 line.wire.prepend(wire);
152 return Ok((line, tail));
153 }
154
155 let mut logical = Vec::from(first);
156 let mut last_eol = eol;
157
158 while starts_with_wsp(tail) {
159 let (continuation, eol, next) = physical_line(&tail[1..]);
160 wire.fold(logical.len(), is_crlf(last_eol), tail[0]);
161 logical.extend_from_slice(continuation);
162 last_eol = eol;
163 tail = next;
164 }
165
166 let mut line = Self::parse(&logical, b"")?.into_static();
167 line.eol = eol_leaf(last_eol);
168 line.wire.prepend(wire.into_static());
169
170 Ok((line, tail))
171 }
172
173 pub fn take_physical(rest: &'a [u8]) -> (&'a [u8], &'a [u8]) {
180 let (content, eol, tail) = physical_line(rest);
181 (&rest[..content.len() + eol.len()], tail)
182 }
183
184 pub(crate) fn into_static(self) -> IcalLine<'static> {
186 IcalLine {
187 name: self.name.into_static(),
188 params: self
189 .params
190 .into_iter()
191 .map(IcalParamNode::into_static)
192 .collect(),
193 value: self.value.into_static(),
194 eol: self.eol.into_static(),
195 wire: self.wire.into_static(),
196 }
197 }
198
199 pub fn raw_value(&self) -> &[u8] {
201 self.value.first_value_bytes()
202 }
203
204 pub fn raw_value_str(&self) -> Cow<'_, str> {
207 String::from_utf8_lossy(self.value.first_value_bytes())
208 }
209
210 pub(crate) fn write_bytes(&self, out: &mut Vec<u8>) {
213 if self.wire.is_empty() {
214 self.write_logical(out);
215 } else {
216 let mut logical = Vec::new();
217 self.write_logical(&mut logical);
218 self.wire.write_bytes(&logical, out);
219 }
220
221 out.extend_from_slice(self.eol.get().as_bytes());
222 }
223
224 fn write_logical(&self, out: &mut Vec<u8>) {
228 out.extend_from_slice(self.name.get().as_bytes());
229
230 for param in &self.params {
231 out.push(b';');
232 param.write_bytes(out);
233 }
234
235 out.push(b':');
236 self.value.write_bytes(out);
237 }
238
239 pub fn param<P: IcalParamLens>(&self) -> Option<P::Target<'_>> {
241 self.params
242 .iter()
243 .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
244 .map(|param| P::decode(param))
245 }
246
247 pub fn param_mut<P: IcalParamLens>(&mut self) -> Option<&mut IcalParamNode<'a>> {
249 self.params
250 .iter_mut()
251 .find(|param| param.name.get().eq_ignore_ascii_case(&P::KIND))
252 }
253
254 fn parse<'b>(content: &'b [u8], eol: &'b [u8]) -> Result<IcalLine<'b>, IcalParseError> {
259 let Some(colon) = memchr::memchr(b':', content) else {
260 return Err(IcalParseError::MissingPropertyColon(lossy(content)));
261 };
262
263 let head = str::from_utf8(&content[..colon])
264 .map_err(|_| IcalParseError::NonUtf8Header(lossy(&content[..colon])))?;
265 let (name, params) = split_head(head);
266
267 let mut value = &content[colon + 1..];
268 let mut wire = IcalWire::default();
269
270 if head_is_quoted_printable(content) {
278 let full = value.len();
279 while value.last() == Some(&b'=') {
280 value = &value[..value.len() - 1];
281 }
282 if value.len() < full {
283 let end = colon + 1 + value.len();
284 wire.skipped(end, ascii(&content[end..colon + 1 + full]));
285 }
286 }
287
288 wire.seal(colon + 1 + value.len());
289
290 Ok(IcalLine {
291 name: IcalLeaf::from(name),
292 params,
293 value: IcalValueNode::parse(value),
294 eol: IcalLeaf::from(str::from_utf8(eol).unwrap_or("")),
295 wire,
296 })
297 }
298}
299
300impl fmt::Display for IcalLine<'_> {
301 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304 if self.wire.is_empty() {
305 f.write_str(self.name.get())?;
306
307 for param in &self.params {
308 write!(f, ";{param}")?;
309 }
310
311 return write!(f, ":{}{}", self.value, self.eol.get());
312 }
313
314 let mut bytes = Vec::new();
315 self.write_bytes(&mut bytes);
316 f.write_str(&String::from_utf8_lossy(&bytes))
317 }
318}
319
320fn split_head(head: &str) -> (&str, Vec<IcalParamNode<'_>>) {
322 let (name, mut rest) = match head.find(';') {
323 Some(semi) => (&head[..semi], &head[semi..]),
324 None => return (head, Vec::new()),
325 };
326
327 let mut params = Vec::new();
328
329 while let Some(after) = rest.strip_prefix(';') {
330 let (param, tail) = match after.find(';') {
331 Some(semi) => (&after[..semi], &after[semi..]),
332 None => (after, ""),
333 };
334
335 params.push(IcalParamNode::parse(param));
336 rest = tail;
337 }
338
339 (name, params)
340}
341
342fn physical_line(rest: &[u8]) -> (&[u8], &[u8], &[u8]) {
346 let Some(lf) = memchr::memchr(b'\n', rest) else {
347 return (rest, b"", b"");
348 };
349
350 let tail = &rest[lf + 1..];
351
352 let (content, eol) = if lf > 0 && rest[lf - 1] == b'\r' {
353 (&rest[..lf - 1], &rest[lf - 1..lf + 1])
354 } else {
355 (&rest[..lf], &rest[lf..lf + 1])
356 };
357
358 (content, eol, tail)
359}
360
361fn starts_with_wsp(rest: &[u8]) -> bool {
363 matches!(rest.first(), Some(b' ' | b'\t'))
364}
365
366fn strip_leading_wsp(mut bytes: &[u8]) -> &[u8] {
370 while matches!(bytes.first(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
371 bytes = &bytes[1..];
372 }
373 bytes
374}
375
376fn head_is_quoted_printable(line: &[u8]) -> bool {
379 let head = match memchr::memchr(b':', line) {
380 Some(colon) => &line[..colon],
381 None => return false,
382 };
383
384 head.split(|&b| b == b';').any(|token| {
385 token.eq_ignore_ascii_case(b"QUOTED-PRINTABLE")
386 || token.eq_ignore_ascii_case(b"ENCODING=QUOTED-PRINTABLE")
387 })
388}
389
390fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
392 IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
393}
394
395fn is_crlf(eol: &[u8]) -> bool {
397 eol.starts_with(b"\r")
398}
399
400fn ascii(bytes: &[u8]) -> &str {
404 str::from_utf8(bytes).unwrap_or("")
405}
406
407fn lossy(bytes: &[u8]) -> String {
409 String::from_utf8_lossy(bytes).into_owned()
410}
411
412#[cfg(test)]
413mod tests {
414 use alloc::string::ToString;
415
416 use crate::tree::{line::IcalLine, value::node::IcalValueNode};
417
418 #[test]
419 fn takes_one_line_and_leaves_the_rest() {
420 let (line, rest) = IcalLine::take(b"FN:John\r\nEND:VCALENDAR\r\n").unwrap();
421 assert_eq!(line.name.get(), "FN");
422 assert_eq!(line.to_string(), "FN:John\r\n");
423 assert_eq!(rest, b"END:VCALENDAR\r\n");
424 }
425
426 #[test]
427 fn splits_parameters_off_the_head_then_round_trips() {
428 let (line, _) = IcalLine::take(b"TEL;TYPE=work,home:123\r\n").unwrap();
429 assert_eq!(line.params.len(), 1);
430 assert_eq!(line.to_string(), "TEL;TYPE=work,home:123\r\n");
431 }
432
433 #[test]
434 fn accepts_a_bare_lf_ending() {
435 let (line, _) = IcalLine::take(b"FN:John\n").unwrap();
436 assert_eq!(line.to_string(), "FN:John\n");
437 }
438
439 #[test]
440 fn unfolds_space_and_tab_continuations() {
441 let (line, rest) =
442 IcalLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCALENDAR\r\n").unwrap();
443 assert_eq!(line.name.get(), "NOTE");
444 assert_eq!(line.raw_value_str(), "foobarbaz");
445 assert_eq!(rest, b"END:VCALENDAR\r\n");
446 }
447
448 #[test]
449 fn serializes_a_folded_line_back_folded() {
450 let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
451 assert_eq!(line.raw_value_str(), "foobar");
452 assert_eq!(line.to_string(), "NOTE:foo\r\n bar\r\n");
453 }
454
455 #[test]
456 fn keeps_the_folding_whitespace_and_the_break_it_arrived_with() {
457 let (line, _) = IcalLine::take(b"NOTE:foo\n\tbar\r\n").unwrap();
458 assert_eq!(line.to_string(), "NOTE:foo\n\tbar\r\n");
459 }
460
461 #[test]
462 fn serializes_a_skipped_blank_line_back() {
463 let (line, _) = IcalLine::take(b"\r\n\r\nFN:John\r\n").unwrap();
464 assert_eq!(line.to_string(), "\r\n\r\nFN:John\r\n");
465 }
466
467 #[test]
468 fn drops_the_fold_points_once_the_value_is_edited() {
469 let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
472 line.value = IcalValueNode::parse(b"something else entirely");
473 assert_eq!(line.to_string(), "NOTE:something else entirely\r\n");
474 }
475
476 #[test]
477 fn keeps_the_fold_points_when_an_edit_keeps_the_length() {
478 let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
481 line.value = IcalValueNode::parse(b"BARFOO");
482 assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
483 }
484
485 #[test]
486 fn keeps_whitespace_beyond_the_single_fold_indicator() {
487 let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
490 assert_eq!(line.raw_value_str(), "foo bar");
491 }
492
493 #[test]
494 fn skips_blank_lines_before_the_next_line() {
495 let (line, rest) = IcalLine::take(b"\r\n\r\nFN:John\r\nEND:VCALENDAR\r\n").unwrap();
496 assert_eq!(line.name.get(), "FN");
497 assert_eq!(rest, b"END:VCALENDAR\r\n");
498 }
499
500 #[test]
501 fn tolerates_a_missing_final_line_break() {
502 let (line, rest) = IcalLine::take(b"END:VCALENDAR").unwrap();
503 assert_eq!(line.name.get(), "END");
504 assert_eq!(line.to_string(), "END:VCALENDAR");
505 assert_eq!(rest, b"");
506 }
507
508 #[test]
509 fn joins_a_quoted_printable_soft_broken_line() {
510 let (line, _) = IcalLine::take(
513 b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCALENDAR\r\n",
514 )
515 .unwrap();
516 assert_eq!(line.name.get(), "NOTE");
517 assert_eq!(line.raw_value_str(), "caf=C3=A9");
518 assert_eq!(line.raw_value(), b"caf=C3=A9");
519 }
520
521 #[test]
522 fn errors_when_there_is_no_content_line() {
523 assert!(IcalLine::take(b"").is_err());
524 assert!(IcalLine::take(b"\r\n\r\n").is_err());
525 }
526
527 #[test]
528 fn rejects_a_non_utf8_head() {
529 let mut raw = b"X-".to_vec();
530 raw.push(0xff);
531 raw.extend_from_slice(b":v\r\n");
532 assert!(IcalLine::take(&raw).is_err());
533 }
534
535 #[test]
536 fn finds_a_parameter_mutably() {
537 use crate::tree::param::language::LANGUAGE;
538
539 let (mut line, _) = IcalLine::take(b"SUMMARY;LANGUAGE=en:Lunch\r\n").unwrap();
540 assert!(line.param_mut::<LANGUAGE>().is_some());
541 }
542
543 #[test]
544 fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
545 assert!(IcalLine::take(b"abc=\r\n").is_err());
548 }
549
550 #[test]
551 fn quoted_printable_join_stops_at_an_empty_tail() {
552 let (line, rest) = IcalLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
556 assert_eq!(line.raw_value_str(), "ab");
557 assert_eq!(rest, b"");
558 }
559}