Skip to main content

ics_core/parser/
line.rs

1//! Logical line tokenization per RFC 5545.
2//!
3//! After unfolding, each logical line has the shape
4//!
5//! ```text
6//! NAME[;PARAM=VALUE...]:VALUE
7//! ```
8//!
9//! This module turns one such text line into a `LogicalLine` token: the
10//! property name (UPPERCASE-normalized), the list of parameters (keys
11//! UPPERCASE, surrounding `"` stripped from values, order preserved),
12//! and the raw text after the colon (escapes intact — TEXT escape
13//! handling lives in ADR-019 Step 2).
14//!
15//! Dispatch sites in `parser/mod.rs` switch on `LogicalLine.name`
16//! instead of `strip_prefix("NAME:")`, which also makes them tolerant of
17//! properties that arrive with parameters (e.g. `UID;X-FOO=bar:abc`).
18
19use crate::raw::RawProperty;
20
21/// Tokenized property line.
22///
23/// Pub-promoted from `pub(crate)` to support out-of-tree consumers like
24/// `icslint` that need to walk the source at the logical-line level
25/// without committing to the typed `VCalendar` view.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct LogicalLine<'a> {
28    /// Property name, UPPERCASE-normalized.
29    pub name: String,
30    /// `(KEY, value)` pairs. Keys UPPERCASE; values keep their original
31    /// casing with surrounding `"` stripped if present.
32    pub params: Vec<(String, String)>,
33    /// Text after the first `:`. Escapes intact; multi-byte UTF-8 intact.
34    pub value: &'a str,
35}
36
37impl<'a> LogicalLine<'a> {
38    /// Build a `RawProperty` for storage in `VEvent.unknown` or a vendor
39    /// bundle's `unrecognized` slot. Allocates because `RawProperty`
40    /// owns its `value`.
41    pub fn to_raw_property(&self, source_index: u32) -> RawProperty {
42        RawProperty {
43            name: self.name.clone(),
44            params: self.params.clone(),
45            value: self.value.to_string(),
46            source_index,
47        }
48    }
49}
50
51/// Parse one logical line into a `LogicalLine`. Returns `None` when the
52/// line is malformed (no colon, no name).
53///
54/// Today this is a minimal parser — it splits on the first `:` and on
55/// `;` boundaries in the prefix, without handling quoted-string param
56/// values that span semicolons. ADR-019 Step 1 brings property routing
57/// onto this primitive; a richer quoted-param parser arrives if/when a
58/// real-world file demands it.
59pub fn parse_logical_line(line: &str) -> Option<LogicalLine<'_>> {
60    let colon = line.find(':')?;
61    let prefix = &line[..colon];
62    let value = &line[colon + 1..];
63
64    let mut parts = prefix.split(';');
65    let raw_name = parts.next()?;
66    if raw_name.is_empty() {
67        return None;
68    }
69    let name = raw_name.to_uppercase();
70    let mut params = Vec::new();
71    for p in parts {
72        if let Some((k, v)) = p.split_once('=') {
73            let v = v.trim_matches('"');
74            params.push((k.to_uppercase(), v.to_string()));
75        }
76    }
77    Some(LogicalLine {
78        name,
79        params,
80        value,
81    })
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn basic_name_value() {
90        let ll = parse_logical_line("UID:abc-123").unwrap();
91        assert_eq!(ll.name, "UID");
92        assert!(ll.params.is_empty());
93        assert_eq!(ll.value, "abc-123");
94    }
95
96    #[test]
97    fn name_uppercase_normalization() {
98        let ll = parse_logical_line("uid:abc").unwrap();
99        assert_eq!(ll.name, "UID");
100    }
101
102    #[test]
103    fn single_param() {
104        let ll = parse_logical_line("DTSTART;VALUE=DATE:20260101").unwrap();
105        assert_eq!(ll.name, "DTSTART");
106        assert_eq!(ll.params, vec![("VALUE".to_string(), "DATE".to_string())]);
107        assert_eq!(ll.value, "20260101");
108    }
109
110    #[test]
111    fn multiple_params_preserve_order() {
112        let ll =
113            parse_logical_line("DTSTART;TZID=Asia/Tokyo;VALUE=DATE-TIME:20260101T090000").unwrap();
114        assert_eq!(
115            ll.params,
116            vec![
117                ("TZID".to_string(), "Asia/Tokyo".to_string()),
118                ("VALUE".to_string(), "DATE-TIME".to_string()),
119            ]
120        );
121        assert_eq!(ll.value, "20260101T090000");
122    }
123
124    #[test]
125    fn param_keys_uppercase_values_keep_case() {
126        let ll = parse_logical_line("X-FOO;lang=ja-JP:hello").unwrap();
127        assert_eq!(ll.params, vec![("LANG".to_string(), "ja-JP".to_string())]);
128    }
129
130    #[test]
131    fn quoted_param_value_strips_quotes() {
132        let ll = parse_logical_line(r#"X-FOO;LANG="ja-JP":hello"#).unwrap();
133        assert_eq!(ll.params, vec![("LANG".to_string(), "ja-JP".to_string())]);
134    }
135
136    #[test]
137    fn missing_colon_yields_none() {
138        assert!(parse_logical_line("UIDabc").is_none());
139    }
140
141    #[test]
142    fn empty_name_yields_none() {
143        assert!(parse_logical_line(":value").is_none());
144    }
145
146    #[test]
147    fn empty_value_is_ok() {
148        let ll = parse_logical_line("UID:").unwrap();
149        assert_eq!(ll.value, "");
150    }
151
152    #[test]
153    fn to_raw_property_copies_name_params_and_assigns_index() {
154        let ll = parse_logical_line("X-CUSTOM-FOO;LANG=en:hello").unwrap();
155        let rp = ll.to_raw_property(7);
156        assert_eq!(rp.name, "X-CUSTOM-FOO");
157        assert_eq!(rp.params, vec![("LANG".to_string(), "en".to_string())]);
158        assert_eq!(rp.value, "hello");
159        assert_eq!(rp.source_index, 7);
160    }
161
162    #[test]
163    fn value_can_contain_colon() {
164        // Only the first colon splits name/params from value.
165        let ll = parse_logical_line("DESCRIPTION:Meeting at 10:00").unwrap();
166        assert_eq!(ll.value, "Meeting at 10:00");
167    }
168
169    #[test]
170    fn multibyte_utf8_in_value() {
171        let ll = parse_logical_line("SUMMARY:憲法記念日").unwrap();
172        assert_eq!(ll.value, "憲法記念日");
173    }
174}