Skip to main content

ical/tree/
line.rs

1//! # Content line
2//!
3//! One raw content line of a calendar: 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) and the head splitter separating the name from its
8//! parameters, but stays generic: what the name means and how the value decodes
9//! belong to the lens markers and the [`codec`](crate::tree::codec).
10//!
11//! Folding, stray blank lines and QUOTED-PRINTABLE soft breaks are resolved on
12//! parse, so every layer above sees one logical line, and recorded on the line's
13//! [`wire`](IcalLine::wire) shape, so serialization puts them back. A calendar
14//! therefore round-trips byte for byte however it was laid out. The final line
15//! needs no trailing break.
16
17use 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/// One raw content line: a name, parameters, a value and the line ending.
31///
32/// This is a *logical* line, not a physical one: [`take`](Self::take) unfolds
33/// RFC 5545 3.1 folded continuations and QUOTED-PRINTABLE soft line breaks, so
34/// a `IcalLine` never holds an internal line break, only its terminating
35/// [`eol`](Self::eol). What it unfolded is kept on [`wire`](Self::wire), which
36/// is what puts the folds back on output. It is also the syntactic unit for the
37/// `BEGIN` / `VERSION` / `END` envelope lines, not only decoded properties.
38#[derive(Clone, Debug)]
39pub struct IcalLine<'a> {
40    /// The property name leaf, with any group prefix.
41    pub name: IcalLeaf<'a>,
42    /// The parameters, in source order.
43    pub params: Vec<IcalParamNode<'a>>,
44    /// The value.
45    pub value: IcalValueNode<'a>,
46    /// The line ending (`\r\n` or `\n`).
47    pub eol: IcalLeaf<'a>,
48    /// How the line was laid out on the wire: its folds, the blank lines before
49    /// it, its soft breaks. Empty for a built line, and dropped on output once
50    /// an edit changes the line's length (see [`IcalWire`]).
51    pub wire: IcalWire<'a>,
52}
53
54impl<'a> IcalLine<'a> {
55    /// Build a property line with a raw text value and the default `\r\n`
56    /// ending. Used to seed BEGIN/VERSION/END and to encode simple values.
57    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    /// Tokenise the logical line at the start of `rest`, unfolding any folded
71    /// continuation lines, and return it with the remaining input. RFC 5545 3.1
72    /// folds a long line by inserting a CRLF and a single leading space or tab;
73    /// unfolding drops them. A line with no folds borrows the source; a folded
74    /// line is rebuilt owned, since its bytes are no longer contiguous.
75    pub fn take(rest: &'a [u8]) -> Result<(Self, &'a [u8]), IcalParseError> {
76        // NOTE: Everything this tokeniser resolves is recorded here, so
77        // serialization can put it back.
78        let mut wire = IcalWire::default();
79
80        // NOTE: skip blank lines: real-world exports sometimes emit them.
81        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        // NOTE: A line that begins with folding whitespace but has no line to
99        // continue (a dangling continuation, e.g. left after a dropped blank
100        // line) would fold into the previous line on reparse; strip the leading
101        // whitespace so it stays its own line, and record it so it still
102        // round-trips.
103        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        // NOTE: QUOTED-PRINTABLE soft line breaks: a line whose head declares
111        // ENCODING=QUOTED-PRINTABLE and whose value ends with `=` continues on
112        // the next physical line. Param-driven, so it applies to any version's
113        // calendar that uses the encoding, not just 2.1.
114        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                            // NOTE: The last continuation ends with a
128                            // soft-break marker and nothing follows: the `=` is
129                            // on the wire, the break after it is the line's own
130                            // ending.
131                            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    /// Split the first physical line off `rest`, verbatim (its content and its
174    /// ending), and return it with what follows.
175    ///
176    /// This is the recovering parser's step over a line [`take`](Self::take)
177    /// refuses: the bytes are kept whole rather than structured, so they still
178    /// round-trip.
179    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    /// Convert into an owned line whose every leaf is owned (`'static`).
185    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    /// The raw bytes of the line's first value, for simple single-value lines.
200    pub fn raw_value(&self) -> &[u8] {
201        self.value.first_value_bytes()
202    }
203
204    /// The raw first value as UTF-8 text, lossily; for the ASCII envelope
205    /// values (`VERSION`) and diagnostics.
206    pub fn raw_value_str(&self) -> Cow<'_, str> {
207        String::from_utf8_lossy(self.value.first_value_bytes())
208    }
209
210    /// Serialize the whole line to bytes, exactly as parsed: its logical
211    /// content, laid back out in the wire shape it arrived in.
212    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    /// Serialize the logical line (its name, parameters and value), with no
225    /// line ending and no wire shape. This is the byte string the wire offsets
226    /// index.
227    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    /// The first parameter of type `P`, decoded.
240    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    /// The first parameter of type `P`, mutably (raw, for editing its leaves).
248    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    /// Split one logical line into a typed line at the colon, separating the
255    /// name, its parameters and the value. The head (name and parameters) must
256    /// be valid UTF-8, as every version's grammar guarantees; only the value
257    /// may carry a foreign charset, so it is kept as raw bytes.
258    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        // NOTE: A QUOTED-PRINTABLE value ending in `=` is a dangling soft-break
271        // marker, however it got there (a soft-break join, a folded
272        // continuation, or raw input): valid content would encode a literal `=`
273        // as `=3D`. Left in, it would re-trigger soft-break joining on reparse
274        // and swallow the next line, so the logical line drops it and the wire
275        // shape keeps it. This never touches base64 padding, since
276        // `ENCODING=BASE64` is not quoted-printable.
277        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    /// The line as text, wire shape included, lossily for a non-UTF-8 value.
302    /// `IcalLine::write_bytes` is the byte-faithful path.
303    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
320/// Split a head into its name and its `;`-separated parameters.
321fn 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
342/// Split the first physical line off `rest`: its content (without the line
343/// ending), its line ending, and the remaining input. A final line with no
344/// trailing break is taken whole, with an empty ending.
345fn 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
361/// Whether `rest` begins with a folding whitespace (space or tab).
362fn starts_with_wsp(rest: &[u8]) -> bool {
363    matches!(rest.first(), Some(b' ' | b'\t'))
364}
365
366/// Strip any leading folding whitespace (space, tab) or stray line-break byte
367/// (`\r`, `\n`) from a line's content, so its name never begins with a byte
368/// that another layer (folding, blank-line skipping) would re-strip on reparse.
369fn 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
376/// Whether a line's head (its name and parameters, before the `:`) declares the
377/// `QUOTED-PRINTABLE` encoding, as an `ENCODING=` parameter or a bare token.
378fn 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
390/// An owned line-ending leaf from raw bytes (always an ASCII `\r\n` / `\n`).
391fn eol_leaf(bytes: &[u8]) -> IcalLeaf<'static> {
392    IcalLeaf::from(String::from_utf8_lossy(bytes).into_owned())
393}
394
395/// Whether a line ending is a `\r\n` rather than a bare `\n`.
396fn is_crlf(eol: &[u8]) -> bool {
397    eol.starts_with(b"\r")
398}
399
400/// Bytes the tokeniser resolved away, as text. Every one of them is a line
401/// break, a space, a tab or an `=`, so the conversion never fails; a lone `""`
402/// on the impossible path keeps this total rather than panicking.
403fn ascii(bytes: &[u8]) -> &str {
404    str::from_utf8(bytes).unwrap_or("")
405}
406
407/// A lossy owned string of raw bytes, for error diagnostics.
408fn 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        // NOTE: The old offsets index bytes that are no longer there, so the
470        // edited line goes out unfolded rather than folded in the wrong places.
471        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        // NOTE: Same length, so every offset still indexes what it did: the
479        // line is folded exactly where it was.
480        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        // NOTE: only the first space is the fold marker; the rest is value
488        // content.
489        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        // NOTE: Two soft breaks: the first continuation itself ends with `=`
511        // (the Some arm), the second does not (the None arm).
512        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        // NOTE: `abc=` ends with `=` but has no colon, so the QP soft-break
546        // check bails and the line then fails for want of a value separator.
547        assert!(IcalLine::take(b"abc=\r\n").is_err());
548    }
549
550    #[test]
551    fn quoted_printable_join_stops_at_an_empty_tail() {
552        // NOTE: The final continuation ends with `=` and nothing follows, so
553        // the join loop exits via the empty-tail guard rather than a non-`=`
554        // line.
555        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}