Skip to main content

ical/tree/
line.rs

1//! # Content line
2//!
3//! One raw content line of a card: name, parameters, value, line ending.
4//!
5//! [`IcalLine`] is the syntactic unit a property occupies. It owns the line
6//! tokeniser ([`take`](IcalLine::take), which splits one logical line off the
7//! remaining input for [`IcalCst::parse`](crate::tree::cst::IcalCst::parse),
8//! unfolding any RFC 5545 3.1 folded continuation lines) and the head splitter
9//! that separates the name from its parameters. It exposes its raw value and
10//! typed parameter access by lens, but stays generic: the meaning of the name
11//! and the decoding of the value belong to the lens markers and the
12//! [`decode`](crate::tree::codec::decode) /
13//! [`encode`](crate::tree::codec::encode) bridges.
14//!
15//! Folding, stray blank lines and QUOTED-PRINTABLE soft breaks are resolved on
16//! parse, so every layer above sees one logical line, and recorded on the
17//! line's [`wire`](IcalLine::wire) shape, so serialization puts them back. A
18//! calendar therefore round-trips byte for byte however it was laid out. The
19//! final line needs no trailing break.
20
21use core::{fmt, str};
22
23use alloc::{borrow::Cow, string::String, vec, vec::Vec};
24
25use crate::tree::{
26    codec::mode::Escaper,
27    error::IcalParseError,
28    leaf::{IcalLeaf, IcalValueLeaf},
29    param::{IcalParamLens, IcalParamNode},
30    value::IcalValueNode,
31    wire::IcalWire,
32};
33
34/// One raw content line: a name, parameters, a value and the line ending.
35///
36/// This is a *logical* line, not a physical one: [`take`](Self::take) unfolds
37/// RFC 5545 3.1 folded continuations and QUOTED-PRINTABLE soft line breaks, so
38/// a `IcalLine` never holds an internal line break, only its terminating
39/// [`eol`](Self::eol). What it unfolded is kept on [`wire`](Self::wire), which
40/// is what puts the folds back on output. It is also the syntactic unit for the
41/// `BEGIN` / `VERSION` / `END` envelope lines, not only decoded properties.
42#[derive(Clone, Debug)]
43pub struct IcalLine<'a> {
44    /// The property name leaf, with any group prefix.
45    pub name: IcalLeaf<'a>,
46    /// The parameters, in source order.
47    pub params: Vec<IcalParamNode<'a>>,
48    /// The value.
49    pub value: IcalValueNode<'a>,
50    /// The line ending (`\r\n` or `\n`).
51    pub eol: IcalLeaf<'a>,
52    /// How the line was laid out on the wire: its folds, the blank lines before
53    /// it, its soft breaks. Empty for a built line, and dropped on output once
54    /// an edit changes the line's length (see [`IcalWire`]).
55    pub wire: IcalWire<'a>,
56}
57
58impl<'a> IcalLine<'a> {
59    /// Build a property line with a raw text value and the default `\r\n`
60    /// ending. Used to seed BEGIN/VERSION/END and to encode simple values.
61    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    /// Tokenise the logical line at the start of `rest`, unfolding any folded
75    /// continuation lines, and return it with the remaining input. RFC 5545 3.1
76    /// folds a long line by inserting a CRLF and a single leading space or tab;
77    /// unfolding drops them. A line with no folds borrows the source; a folded
78    /// line is rebuilt owned, since its bytes are no longer contiguous.
79    pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
80        // NOTE: Everything this tokeniser resolves is recorded here, so
81        // serialization can put it back.
82        let mut wire = IcalWire::default();
83
84        // NOTE: skip blank lines: real-world exports sometimes emit them.
85        let mut head = rest;
86        let (first, eol, mut tail) = loop {
87            if head.is_empty() {
88                return Err(IcalParseError::MissingCrlf(lossy(rest)));
89            }
90            let (content, eol, next) = physical_line(head);
91            if content.is_empty() {
92                head = next;
93                continue;
94            }
95            break (content, eol, next);
96        };
97
98        if head.len() < rest.len() {
99            wire.skipped(0, ascii(&rest[..rest.len() - head.len()]));
100        }
101
102        // NOTE: A line that begins with folding whitespace but has no line to
103        // continue (a dangling continuation, e.g. left after a dropped blank
104        // line) would fold into the previous line on reparse; strip the leading
105        // whitespace so it stays its own line, and record it so it still
106        // round-trips.
107        let indented = first;
108        let first = strip_leading_wsp(first);
109
110        if first.len() < indented.len() {
111            wire.skipped(0, ascii(&indented[..indented.len() - first.len()]));
112        }
113
114        // NOTE: QUOTED-PRINTABLE soft line breaks: a line whose head declares
115        // ENCODING=QUOTED-PRINTABLE and whose value ends with `=` continues on
116        // the next physical line. Param-driven, so it applies to any version's
117        // card that uses the encoding, not just 2.1.
118        if first.ends_with(b"=") && head_is_quoted_printable(first) {
119            let mut logical = Vec::from(&first[..first.len() - 1]);
120            wire.soft(logical.len(), is_crlf(eol));
121
122            let mut last_eol;
123            loop {
124                let (continuation, eol, next) = physical_line(tail);
125                last_eol = eol;
126                tail = next;
127                match continuation.strip_suffix(b"=") {
128                    Some(head) => {
129                        logical.extend_from_slice(head);
130                        if tail.is_empty() {
131                            // NOTE: The last continuation ends with a
132                            // soft-break marker and nothing follows: the `=` is
133                            // on the wire, the break after it is the line's own
134                            // ending.
135                            wire.skipped(logical.len(), "=");
136                            break;
137                        }
138                        wire.soft(logical.len(), is_crlf(eol));
139                    }
140                    None => {
141                        logical.extend_from_slice(continuation);
142                        break;
143                    }
144                }
145            }
146
147            let mut line = Self::parse(&logical, b"")?.into_static();
148            line.eol = eol_leaf(last_eol);
149            line.wire.prepend(wire.into_static());
150            return Ok((line, tail));
151        }
152
153        if !starts_with_wsp(tail) {
154            let mut line = Self::parse(first, eol)?;
155            line.wire.prepend(wire);
156            return Ok((line, tail));
157        }
158
159        let mut logical = Vec::from(first);
160        let mut last_eol = eol;
161
162        while starts_with_wsp(tail) {
163            let (continuation, eol, next) = physical_line(&tail[1..]);
164            wire.fold(logical.len(), is_crlf(last_eol), tail[0]);
165            logical.extend_from_slice(continuation);
166            last_eol = eol;
167            tail = next;
168        }
169
170        let mut line = Self::parse(&logical, b"")?.into_static();
171        line.eol = eol_leaf(last_eol);
172        line.wire.prepend(wire.into_static());
173
174        Ok((line, tail))
175    }
176
177    /// Split the first physical line off `rest`, verbatim (its content and its
178    /// ending), and return it with what follows.
179    ///
180    /// This is the recovering parser's step over a line [`take`](Self::take)
181    /// refuses: the bytes are kept whole rather than structured, so they still
182    /// round-trip.
183    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    /// Convert into an owned line whose every leaf is owned (`'static`).
189    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    /// The raw bytes of the line's first value, for simple single-value lines.
204    pub fn raw_value(&self) -> &[u8] {
205        self.value.first_value_bytes()
206    }
207
208    /// The raw first value as UTF-8 text, lossily; for the ASCII envelope
209    /// values (`VERSION`) and diagnostics.
210    pub fn raw_value_str(&self) -> Cow<'_, str> {
211        String::from_utf8_lossy(self.value.first_value_bytes())
212    }
213
214    /// Serialize the whole line to bytes, exactly as parsed: its logical
215    /// content, laid back out in the wire shape it arrived in.
216    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    /// Serialize the logical line (its name, parameters and value), with no
229    /// line ending and no wire shape. This is the byte string the wire offsets
230    /// index.
231    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    /// The first parameter of type `P`, decoded.
244    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    /// The first parameter of type `P`, mutably (raw, for editing its leaves).
252    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    /// Split one logical line into a typed line at the colon, separating the
259    /// name, its parameters and the value. The head (name and parameters) must
260    /// be valid UTF-8, as every version's grammar guarantees; only the value
261    /// may carry a foreign charset, so it is kept as raw bytes.
262    fn parse<'b>(content: &'b [u8], eol: &'b [u8]) -> Result<IcalLine<'b>, IcalParseError> {
263        let Some(colon) = memchr::memchr(b':', 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        // NOTE: A QUOTED-PRINTABLE value ending in `=` is a dangling soft-break
275        // marker, however it got there (a soft-break join, a folded
276        // continuation, or raw input): valid content would encode a literal `=`
277        // as `=3D`. Left in, it would re-trigger soft-break joining on reparse
278        // and swallow the next line, so the logical line drops it and the wire
279        // shape keeps it. This never touches base64 padding, since
280        // `ENCODING=BASE64` is not quoted-printable.
281        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    /// The line as text, wire shape included, lossily for a non-UTF-8 value.
306    /// `IcalLine::write_bytes` is the byte-faithful path.
307    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
324/// Split a head into its name and its `;`-separated parameters.
325fn split_head(head: &str) -> (&str, Vec<IcalParamNode<'_>>) {
326    let (name, mut rest) = match head.find(';') {
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 after.find(';') {
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
346/// Split the first physical line off `rest`: its content (without the line
347/// ending), its line ending, and the remaining input. A final line with no
348/// trailing break is taken whole, with an empty ending.
349fn physical_line(rest: &[u8]) -> (&[u8], &[u8], &[u8]) {
350    let Some(lf) = memchr::memchr(b'\n', rest) else {
351        return (rest, b"", b"");
352    };
353
354    let tail = &rest[lf + 1..];
355
356    let (content, eol) = if lf > 0 && rest[lf - 1] == b'\r' {
357        (&rest[..lf - 1], &rest[lf - 1..lf + 1])
358    } else {
359        (&rest[..lf], &rest[lf..lf + 1])
360    };
361
362    (content, eol, tail)
363}
364
365/// Whether `rest` begins with a folding whitespace (space or tab).
366fn starts_with_wsp(rest: &[u8]) -> bool {
367    matches!(rest.first(), Some(b' ' | b'\t'))
368}
369
370/// Strip any leading folding whitespace (space, tab) or stray line-break byte
371/// (`\r`, `\n`) from a line's content, so its name never begins with a byte
372/// that another layer (folding, blank-line skipping) would re-strip on reparse.
373fn strip_leading_wsp(mut bytes: &[u8]) -> &[u8] {
374    while matches!(bytes.first(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
375        bytes = &bytes[1..];
376    }
377    bytes
378}
379
380/// Whether a line's head (its name and parameters, before the `:`) declares the
381/// `QUOTED-PRINTABLE` encoding, as an `ENCODING=` parameter or a bare token.
382fn head_is_quoted_printable(line: &[u8]) -> bool {
383    let head = match memchr::memchr(b':', line) {
384        Some(colon) => &line[..colon],
385        None => return false,
386    };
387
388    head.split(|&b| b == b';').any(|token| {
389        token.eq_ignore_ascii_case(b"QUOTED-PRINTABLE")
390            || token.eq_ignore_ascii_case(b"ENCODING=QUOTED-PRINTABLE")
391    })
392}
393
394/// An owned line-ending leaf from raw bytes (always an ASCII `\r\n` / `\n`).
395fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
396    IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
397}
398
399/// Whether a line ending is a `\r\n` rather than a bare `\n`.
400fn is_crlf(eol: &[u8]) -> bool {
401    eol.starts_with(b"\r")
402}
403
404/// Bytes the tokeniser resolved away, as text. Every one of them is a line
405/// break, a space, a tab or an `=`, so the conversion never fails; a lone `""`
406/// on the impossible path keeps this total rather than panicking.
407fn ascii(bytes: &[u8]) -> &str {
408    str::from_utf8(bytes).unwrap_or("")
409}
410
411/// A lossy owned string of raw bytes, for error diagnostics.
412fn lossy(bytes: &[u8]) -> String {
413    String::from_utf8_lossy(bytes).into_owned()
414}
415
416#[cfg(test)]
417mod tests {
418    use alloc::string::ToString;
419
420    use crate::tree::{line::IcalLine, value::IcalValueNode};
421
422    #[test]
423    fn takes_one_line_and_leaves_the_rest() {
424        let (line, rest) = IcalLine::take(b"FN:John\r\nEND:VCALENDAR\r\n").unwrap();
425        assert_eq!(line.name.get(), "FN");
426        assert_eq!(line.to_string(), "FN:John\r\n");
427        assert_eq!(rest, b"END:VCALENDAR\r\n");
428    }
429
430    #[test]
431    fn splits_parameters_off_the_head_then_round_trips() {
432        let (line, _) = IcalLine::take(b"TEL;TYPE=work,home:123\r\n").unwrap();
433        assert_eq!(line.params.len(), 1);
434        assert_eq!(line.to_string(), "TEL;TYPE=work,home:123\r\n");
435    }
436
437    #[test]
438    fn accepts_a_bare_lf_ending() {
439        let (line, _) = IcalLine::take(b"FN:John\n").unwrap();
440        assert_eq!(line.to_string(), "FN:John\n");
441    }
442
443    #[test]
444    fn unfolds_space_and_tab_continuations() {
445        let (line, rest) =
446            IcalLine::take(b"NOTE:foo\r\n bar\r\n\tbaz\r\nEND:VCALENDAR\r\n").unwrap();
447        assert_eq!(line.name.get(), "NOTE");
448        assert_eq!(line.raw_value_str(), "foobarbaz");
449        assert_eq!(rest, b"END:VCALENDAR\r\n");
450    }
451
452    #[test]
453    fn serializes_a_folded_line_back_folded() {
454        let (line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
455        assert_eq!(line.raw_value_str(), "foobar");
456        assert_eq!(line.to_string(), "NOTE:foo\r\n bar\r\n");
457    }
458
459    #[test]
460    fn keeps_the_folding_whitespace_and_the_break_it_arrived_with() {
461        let (line, _) = IcalLine::take(b"NOTE:foo\n\tbar\r\n").unwrap();
462        assert_eq!(line.to_string(), "NOTE:foo\n\tbar\r\n");
463    }
464
465    #[test]
466    fn serializes_a_skipped_blank_line_back() {
467        let (line, _) = IcalLine::take(b"\r\n\r\nFN:John\r\n").unwrap();
468        assert_eq!(line.to_string(), "\r\n\r\nFN:John\r\n");
469    }
470
471    #[test]
472    fn drops_the_fold_points_once_the_value_is_edited() {
473        // NOTE: The old offsets index bytes that are no longer there, so the
474        // edited line goes out unfolded rather than folded in the wrong places.
475        let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
476        line.value = IcalValueNode::parse(b"something else entirely");
477        assert_eq!(line.to_string(), "NOTE:something else entirely\r\n");
478    }
479
480    #[test]
481    fn keeps_the_fold_points_when_an_edit_keeps_the_length() {
482        // NOTE: Same length, so every offset still indexes what it did: the
483        // line is folded exactly where it was.
484        let (mut line, _) = IcalLine::take(b"NOTE:foo\r\n bar\r\n").unwrap();
485        line.value = IcalValueNode::parse(b"BARFOO");
486        assert_eq!(line.to_string(), "NOTE:BAR\r\n FOO\r\n");
487    }
488
489    #[test]
490    fn keeps_whitespace_beyond_the_single_fold_indicator() {
491        // NOTE: only the first space is the fold marker; the rest is value
492        // content.
493        let (line, _) = IcalLine::take(b"NOTE:foo\r\n  bar\r\n").unwrap();
494        assert_eq!(line.raw_value_str(), "foo bar");
495    }
496
497    #[test]
498    fn skips_blank_lines_before_the_next_line() {
499        let (line, rest) = IcalLine::take(b"\r\n\r\nFN:John\r\nEND:VCALENDAR\r\n").unwrap();
500        assert_eq!(line.name.get(), "FN");
501        assert_eq!(rest, b"END:VCALENDAR\r\n");
502    }
503
504    #[test]
505    fn tolerates_a_missing_final_line_break() {
506        let (line, rest) = IcalLine::take(b"END:VCALENDAR").unwrap();
507        assert_eq!(line.name.get(), "END");
508        assert_eq!(line.to_string(), "END:VCALENDAR");
509        assert_eq!(rest, b"");
510    }
511
512    #[test]
513    fn joins_a_quoted_printable_soft_broken_line() {
514        // NOTE: Two soft breaks: the first continuation itself ends with `=`
515        // (the Some arm), the second does not (the None arm).
516        let (line, _) = IcalLine::take(
517            b"NOTE;ENCODING=QUOTED-PRINTABLE:caf=\r\n=C3=\r\n=A9\r\nEND:VCALENDAR\r\n",
518        )
519        .unwrap();
520        assert_eq!(line.name.get(), "NOTE");
521        assert_eq!(line.raw_value_str(), "caf=C3=A9");
522        assert_eq!(line.raw_value(), b"caf=C3=A9");
523    }
524
525    #[test]
526    fn errors_when_there_is_no_content_line() {
527        assert!(IcalLine::take(b"").is_err());
528        assert!(IcalLine::take(b"\r\n\r\n").is_err());
529    }
530
531    #[test]
532    fn rejects_a_non_utf8_head() {
533        let mut raw = b"X-".to_vec();
534        raw.push(0xff);
535        raw.extend_from_slice(b":v\r\n");
536        assert!(IcalLine::take(&raw).is_err());
537    }
538
539    #[test]
540    fn finds_a_parameter_mutably() {
541        use crate::tree::param::language::LANGUAGE;
542
543        let (mut line, _) = IcalLine::take(b"SUMMARY;LANGUAGE=en:Lunch\r\n").unwrap();
544        assert!(line.param_mut::<LANGUAGE>().is_some());
545    }
546
547    #[test]
548    fn a_trailing_equals_without_a_colon_is_not_quoted_printable() {
549        // NOTE: `abc=` ends with `=` but has no colon, so the QP soft-break
550        // check bails and the line then fails for want of a value separator.
551        assert!(IcalLine::take(b"abc=\r\n").is_err());
552    }
553
554    #[test]
555    fn quoted_printable_join_stops_at_an_empty_tail() {
556        // NOTE: The final continuation ends with `=` and nothing follows, so
557        // the join loop exits via the empty-tail guard rather than a non-`=`
558        // line.
559        let (line, rest) = IcalLine::take(b"NOTE;ENCODING=QUOTED-PRINTABLE:a=\r\nb=\r\n").unwrap();
560        assert_eq!(line.raw_value_str(), "ab");
561        assert_eq!(rest, b"");
562    }
563}