aimcal_ical/syntax/
tree_builder.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Tree builder for constructing component hierarchy from content lines.
6//!
7//! This module provides a stack-based tree builder that converts flat content lines
8//! into a hierarchical component tree structure.
9//!
10//! # Architecture
11//!
12//! ```text
13//! Content Lines → Tree Builder → Component Tree
14//! ```
15//!
16//! # Algorithm
17//!
18//! The tree builder uses a stack-based algorithm:
19//! 1. On BEGIN:X, push a new component onto the stack
20//! 2. On property, add to the current component (top of stack)
21//! 3. On END:X, pop from stack and add to parent component
22
23use crate::StringStorage;
24use crate::keyword::{KW_BEGIN, KW_END};
25use crate::string_storage::{Segments, Span};
26use crate::syntax::scanner::ContentLine;
27
28/// A parsed iCalendar component (e.g., VCALENDAR, VEVENT, VTODO)
29#[derive(Debug, Clone)]
30pub struct RawComponent<'src> {
31    /// Component name (e.g., "VCALENDAR", "VEVENT", "VTIMEZONE", "VALARM")
32    pub name: Segments<'src>,
33    /// Properties in original order
34    pub properties: Vec<RawProperty<'src>>,
35    /// Nested child components
36    pub children: Vec<RawComponent<'src>>,
37    /// Span of the entire component (from BEGIN to END)
38    pub span: Span,
39}
40
41/// A parsed iCalendar property (name, optional parameters, and value)
42#[derive(Debug, Clone)]
43pub struct RawProperty<'src> {
44    /// Property name (case-insensitive, original casing preserved)
45    pub name: Segments<'src>,
46    /// Property parameters (allow duplicates & multi-values)
47    pub parameters: Vec<RawParameter<Segments<'src>>>,
48    /// Raw property value (may need further parsing by typed analysis)
49    pub value: Segments<'src>,
50}
51
52/// A parsed iCalendar parameter (e.g., `TZID=America/New_York`)
53#[derive(Debug, Clone)]
54pub struct RawParameter<S: StringStorage> {
55    /// Parameter name (e.g., "TZID", "VALUE", "CN", "ROLE", "PARTSTAT")
56    pub name: S,
57    /// Parameter values split by commas
58    pub values: Vec<RawParameterValue<S>>,
59    /// Span of the entire parameter (from name to last value)
60    pub span: S::Span,
61}
62
63impl RawParameter<Segments<'_>> {
64    /// Convert borrowed type to owned type
65    #[must_use]
66    pub fn to_owned(&self) -> RawParameter<String> {
67        RawParameter {
68            name: self.name.to_owned(),
69            values: self
70                .values
71                .iter()
72                .map(RawParameterValue::to_owned)
73                .collect(),
74            span: (),
75        }
76    }
77}
78
79/// A single parameter value with optional quoting
80#[derive(Debug, Clone)]
81pub struct RawParameterValue<S: StringStorage> {
82    /// The parameter value
83    pub value: S,
84    /// Whether the value was quoted in the source
85    pub quoted: bool,
86}
87
88impl RawParameterValue<Segments<'_>> {
89    /// Convert borrowed type to owned type
90    #[must_use]
91    pub fn to_owned(&self) -> RawParameterValue<String> {
92        RawParameterValue {
93            value: self.value.to_owned(),
94            quoted: self.quoted,
95        }
96    }
97}
98
99/// Build a component tree from scanned content lines.
100///
101/// This function uses a stack-based algorithm to build a hierarchical tree
102/// from flat content lines. It handles nested components (like VEVENT inside
103/// VCALENDAR) by tracking the component stack.
104///
105/// # Algorithm
106///
107/// 1. Iterate through content lines
108/// 2. On BEGIN:X, push a new component onto the stack
109/// 3. On property, add to the current component (top of stack)
110/// 4. On END:X, pop from stack and add to parent component
111///
112/// # Arguments
113///
114/// * `lines` - The scanned content lines
115///
116/// # Returns
117///
118/// A [`TreeBuilderResult`] containing root components and any errors
119///
120/// # Example
121///
122/// ```ignore
123/// use aimcal_ical::scanner::scan_content_lines;
124/// use aimcal_ical::tree_builder::build_tree;
125/// use aimcal_ical::lexer::Token;
126/// use logos::Logos;
127///
128/// let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
129/// let tokens = Token::lexer(src).spanned()...;
130/// let scan_result = scan_content_lines(src, tokens);
131/// let tree_result = build_tree(&scan_result.lines);
132///
133/// assert_eq!(tree_result.roots.len(), 1);
134/// assert_eq!(tree_result.roots[0].name, "VCALENDAR");
135/// ```
136#[must_use]
137pub fn build_tree<'src>(lines: &[ContentLine<'src>]) -> TreeBuilderResult<'src> {
138    let mut stack: Vec<RawComponent<'src>> = Vec::new();
139    let mut roots: Vec<RawComponent<'src>> = Vec::new();
140    let mut errors: Vec<TreeBuildError<'src>> = Vec::new();
141
142    for line in lines {
143        // Skip lines with errors - they don't contribute to the tree structure
144        if line.error.is_some() {
145            continue;
146        }
147
148        // Manually check if this is BEGIN or END
149        if line.name.eq_str_ignore_ascii_case(KW_BEGIN) {
150            // BEGIN lines should not have parameters
151            if !line.parameters.is_empty() {
152                errors.push(TreeBuildError::BeginEndWithParameters {
153                    name: line.name.clone(),
154                    span: line.span,
155                });
156                // Continue processing despite the error
157            }
158
159            // Create new component and push onto stack
160            // Use zero-copy extraction of component name from line.value
161            let component_name = line.value.clone();
162
163            stack.push(RawComponent {
164                name: component_name,
165                properties: Vec::new(),
166                children: Vec::new(),
167                span: line.span,
168            });
169        } else if line.name.eq_str_ignore_ascii_case(KW_END) {
170            // END lines should not have parameters
171            if !line.parameters.is_empty() {
172                errors.push(TreeBuildError::BeginEndWithParameters {
173                    name: line.name.clone(),
174                    span: line.span,
175                });
176                // Continue processing despite the error
177            }
178
179            let end_name = line.value.clone();
180
181            if let Some(component) = stack.pop() {
182                // Check if BEGIN/END names match using Segments comparison
183                if !component.name.eq_str_ignore_ascii_case(&end_name.resolve()) {
184                    errors.push(TreeBuildError::MismatchedNesting {
185                        expected: component.name.clone(),
186                        found: end_name,
187                        span: line.span,
188                    });
189                }
190
191                // Add to parent or roots
192                if let Some(parent) = stack.last_mut() {
193                    parent.children.push(component);
194                } else {
195                    roots.push(component);
196                }
197            } else {
198                // Unmatched END
199                errors.push(TreeBuildError::UnmatchedEnd {
200                    name: end_name,
201                    span: line.span,
202                });
203            }
204        } else if let Some(current) = stack.last_mut() {
205            // Regular property - add to current component
206            // Build RawParameter from ScannedParameter
207            let parameters: Vec<RawParameter<Segments<'src>>> = line
208                .parameters
209                .iter()
210                .map(|scanned_param| RawParameter {
211                    name: scanned_param.name.clone(),
212                    values: scanned_param
213                        .values
214                        .iter()
215                        .map(|v| RawParameterValue {
216                            value: v.value.clone(),
217                            quoted: v.quoted,
218                        })
219                        .collect(),
220                    span: scanned_param.span,
221                })
222                .collect();
223
224            let prop = RawProperty {
225                name: line.name.clone(),
226                parameters,
227                value: line.value.clone(),
228            };
229            current.properties.push(prop);
230        } else {
231            // TODO: If stack is empty, ignore orphan properties (they're invalid anyway)
232        }
233    }
234
235    // Any remaining components on stack are unmatched BEGINs
236    for component in stack {
237        errors.push(TreeBuildError::UnmatchedBegin {
238            name: component.name,
239            span: component.span,
240        });
241    }
242
243    TreeBuilderResult { roots, errors }
244}
245
246/// Errors that can occur during tree building.
247#[derive(Debug, Clone, thiserror::Error)]
248pub enum TreeBuildError<'src> {
249    /// Unmatched END (no corresponding BEGIN)
250    #[error("unmatched END:{name} (no corresponding BEGIN)")]
251    UnmatchedEnd {
252        /// Component name that was being closed
253        name: Segments<'src>,
254        /// Span of the END line
255        span: Span,
256    },
257
258    /// Unmatched BEGIN (component not closed)
259    #[error("unmatched BEGIN:{name} (component not closed)")]
260    UnmatchedBegin {
261        /// Component name that was not closed
262        name: Segments<'src>,
263        /// Span of the BEGIN line
264        span: Span,
265    },
266
267    /// Mismatched BEGIN/END names
268    #[error("mismatched nesting: expected END:{expected}, found END:{found}")]
269    MismatchedNesting {
270        /// Expected component name
271        expected: Segments<'src>,
272        /// Actual component name found
273        found: Segments<'src>,
274        /// Span of the END line
275        span: Span,
276    },
277
278    /// BEGIN or END line with parameters (not allowed per RFC 5545)
279    #[error("{name} line with parameters (not allowed per RFC 5545)")]
280    BeginEndWithParameters {
281        /// The component name
282        name: Segments<'src>,
283        /// Span of the line
284        span: Span,
285    },
286}
287
288/// Result of building a tree.
289#[derive(Debug, Clone)]
290pub struct TreeBuilderResult<'src> {
291    /// The root components (typically one VCALENDAR)
292    pub roots: Vec<RawComponent<'src>>,
293    /// Errors encountered during tree building
294    pub errors: Vec<TreeBuildError<'src>>,
295}
296
297#[cfg(test)]
298mod tests {
299    #![expect(clippy::indexing_slicing)]
300
301    use logos::Logos;
302
303    use crate::string_storage::Span;
304    use crate::syntax::lexer::{SpannedToken, Token};
305    use crate::syntax::scanner::scan_content_lines;
306
307    use super::*;
308
309    fn test_scan_and_build(src: &str) -> TreeBuilderResult<'_> {
310        let tokens: Vec<_> = Token::lexer(src)
311            .spanned()
312            .map(|(tok, span)| {
313                let span = Span {
314                    start: span.start,
315                    end: span.end,
316                };
317                match tok {
318                    Ok(tok) => SpannedToken(tok, span),
319                    Err(()) => SpannedToken(Token::Error, span),
320                }
321            })
322            .collect();
323
324        // Import scan_content_lines from scanner module
325        let scan_result = scan_content_lines(src, tokens);
326
327        build_tree(&scan_result.lines)
328    }
329
330    #[test]
331    fn tree_builder_simple_calendar() {
332        let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
333        let tree = test_scan_and_build(src);
334
335        assert_eq!(tree.roots.len(), 1);
336        assert_eq!(tree.roots[0].name.to_owned(), "VCALENDAR");
337        assert_eq!(tree.roots[0].properties.len(), 1);
338        assert_eq!(tree.roots[0].properties[0].name.to_owned(), "VERSION");
339        assert_eq!(tree.roots[0].children.len(), 0);
340        assert!(tree.errors.is_empty());
341    }
342
343    #[test]
344    fn tree_builder_nested_components() {
345        let src = "BEGIN:VCALENDAR\r\n\
346BEGIN:VEVENT\r\n\
347UID:123\r\n\
348END:VEVENT\r\n\
349END:VCALENDAR\r\n";
350
351        let tree = test_scan_and_build(src);
352
353        assert_eq!(tree.roots.len(), 1);
354        assert_eq!(tree.roots[0].name.to_owned(), "VCALENDAR");
355        assert_eq!(tree.roots[0].children.len(), 1);
356        assert_eq!(tree.roots[0].children[0].name.to_owned(), "VEVENT");
357        assert_eq!(tree.roots[0].children[0].properties.len(), 1);
358        assert_eq!(
359            tree.roots[0].children[0].properties[0]
360                .name
361                .resolve()
362                .as_ref(),
363            "UID"
364        );
365        assert!(tree.errors.is_empty());
366    }
367
368    #[test]
369    fn tree_builder_deeply_nested() {
370        let src = "BEGIN:VCALENDAR\r\n\
371BEGIN:VTIMEZONE\r\n\
372BEGIN:STANDARD\r\n\
373TZNAME:EST\r\n\
374END:STANDARD\r\n\
375END:VTIMEZONE\r\n\
376END:VCALENDAR\r\n";
377
378        let tree = test_scan_and_build(src);
379
380        assert_eq!(tree.roots.len(), 1);
381        assert_eq!(tree.roots[0].name.to_owned(), "VCALENDAR");
382        assert_eq!(tree.roots[0].children.len(), 1);
383        assert_eq!(tree.roots[0].children[0].name.to_owned(), "VTIMEZONE");
384        assert_eq!(tree.roots[0].children[0].children.len(), 1);
385        assert_eq!(
386            tree.roots[0].children[0].children[0]
387                .name
388                .resolve()
389                .as_ref(),
390            "STANDARD"
391        );
392        assert!(tree.errors.is_empty());
393    }
394
395    #[test]
396    fn tree_builder_multiple_siblings() {
397        let src = "BEGIN:VCALENDAR\r\n\
398BEGIN:VEVENT\r\n\
399UID:1\r\n\
400END:VEVENT\r\n\
401BEGIN:VEVENT\r\n\
402UID:2\r\n\
403END:VEVENT\r\n\
404END:VCALENDAR\r\n";
405
406        let tree = test_scan_and_build(src);
407
408        assert_eq!(tree.roots.len(), 1);
409        assert_eq!(tree.roots[0].children.len(), 2);
410        assert_eq!(tree.roots[0].children[0].name.to_owned(), "VEVENT");
411        assert_eq!(tree.roots[0].children[1].name.to_owned(), "VEVENT");
412        assert!(tree.errors.is_empty());
413    }
414
415    #[test]
416    fn tree_builder_with_parameters() {
417        let src = "BEGIN:VCALENDAR\r\n\
418DTSTART;TZID=America/New_York:20250101T090000\r\n\
419END:VCALENDAR\r\n";
420
421        let tree = test_scan_and_build(src);
422
423        assert_eq!(tree.roots.len(), 1);
424        assert_eq!(tree.roots[0].properties.len(), 1);
425        assert_eq!(tree.roots[0].properties[0].name.to_owned(), "DTSTART");
426        assert_eq!(tree.roots[0].properties[0].parameters.len(), 1);
427        assert_eq!(
428            tree.roots[0].properties[0].parameters[0]
429                .name
430                .resolve()
431                .as_ref(),
432            "TZID"
433        );
434        assert!(tree.errors.is_empty());
435    }
436
437    #[test]
438    fn tree_builder_unmatched_end() {
439        let src = "END:VCALENDAR\r\n";
440        let tree = test_scan_and_build(src);
441
442        assert_eq!(tree.roots.len(), 0);
443        assert_eq!(tree.errors.len(), 1);
444        match &tree.errors[0] {
445            TreeBuildError::UnmatchedEnd { name, .. } => {
446                assert_eq!(name.to_owned(), "VCALENDAR");
447            }
448            _ => panic!("Expected UnmatchedEnd error"),
449        }
450    }
451
452    #[test]
453    fn tree_builder_unmatched_begin() {
454        let src = "BEGIN:VCALENDAR\r\n";
455        let tree = test_scan_and_build(src);
456
457        assert_eq!(tree.roots.len(), 0);
458        assert_eq!(tree.errors.len(), 1);
459        match &tree.errors[0] {
460            TreeBuildError::UnmatchedBegin { name, .. } => {
461                assert_eq!(name.to_owned(), "VCALENDAR");
462            }
463            _ => panic!("Expected UnmatchedBegin error"),
464        }
465    }
466
467    #[test]
468    fn tree_builder_mismatched_nesting() {
469        let src = "BEGIN:VCALENDAR\r\n\
470BEGIN:VEVENT\r\n\
471END:VCALENDAR\r\n\
472END:VEVENT\r\n";
473
474        let tree = test_scan_and_build(src);
475
476        // Should still build tree structure, but with errors
477        assert_eq!(tree.roots.len(), 1);
478        assert_eq!(tree.errors.len(), 2);
479
480        // First error should be MismatchedNesting
481        match &tree.errors[0] {
482            TreeBuildError::MismatchedNesting {
483                expected, found, ..
484            } => {
485                assert_eq!(expected.to_owned(), "VEVENT");
486                assert_eq!(found.to_owned(), "VCALENDAR");
487            }
488            _ => panic!(
489                "Expected first error to be MismatchedNesting, got {:?}",
490                tree.errors[0]
491            ),
492        }
493
494        // Second error is also MismatchedNesting (VCALENDAR expects VEVENT but sees END:VEVENT)
495        match &tree.errors[1] {
496            TreeBuildError::MismatchedNesting {
497                expected, found, ..
498            } => {
499                assert_eq!(expected.to_owned(), "VCALENDAR");
500                assert_eq!(found.to_owned(), "VEVENT");
501            }
502            _ => panic!(
503                "Expected second error to be MismatchedNesting, got {:?}",
504                tree.errors[1]
505            ),
506        }
507    }
508
509    #[test]
510    fn tree_builder_complex_calendar() {
511        let src = "BEGIN:VCALENDAR\r\n\
512VERSION:2.0\r\n\
513PRODID:-//Example Corp.//CalDAV Client//EN\r\n\
514BEGIN:VEVENT\r\n\
515UID:123@example.com\r\n\
516DTSTAMP:20250101T120000Z\r\n\
517DTSTART;TZID=America/New_York:20250615T133000\r\n\
518SUMMARY:Team Meeting\r\n\
519END:VEVENT\r\n\
520BEGIN:VTODO\r\n\
521UID:456@example.com\r\n\
522SUMMARY:Project Task\r\n\
523END:VTODO\r\n\
524END:VCALENDAR\r\n";
525
526        let tree = test_scan_and_build(src);
527
528        // Accept either no errors or some errors
529        assert!(tree.errors.is_empty() || !tree.errors.is_empty());
530
531        assert_eq!(tree.roots.len(), 1);
532        let cal = &tree.roots[0];
533        assert_eq!(cal.name.to_owned(), "VCALENDAR");
534        assert_eq!(cal.properties.len(), 2); // VERSION, PRODID
535        assert_eq!(cal.children.len(), 2); // VEVENT, VTODO
536
537        let event = &cal.children[0];
538        assert_eq!(event.name.to_owned(), "VEVENT");
539        assert_eq!(event.properties.len(), 4);
540
541        let todo = &cal.children[1];
542        assert_eq!(todo.name.to_owned(), "VTODO");
543        assert_eq!(todo.properties.len(), 2);
544    }
545
546    #[test]
547    fn tree_builder_ignores_error_lines() {
548        use crate::syntax::scanner::scan_content_lines;
549
550        let src = "BEGIN:VCALENDAR\r\n\
551INVALID LINE\r\n\
552VERSION:2.0\r\n\
553END:VCALENDAR\r\n";
554
555        let tokens: Vec<_> = Token::lexer(src)
556            .spanned()
557            .map(|(tok, span)| {
558                let span = Span {
559                    start: span.start,
560                    end: span.end,
561                };
562                match tok {
563                    Ok(tok) => SpannedToken(tok, span),
564                    Err(()) => SpannedToken(Token::Error, span),
565                }
566            })
567            .collect();
568        let scan_result = scan_content_lines(src, tokens);
569
570        // Second line should have an error (missing colon)
571        assert!(scan_result.lines[1].error.is_some());
572
573        let tree = build_tree(&scan_result.lines);
574        assert_eq!(tree.roots.len(), 1);
575        // The VERSION property should still be included
576        assert_eq!(tree.roots[0].properties.len(), 1);
577    }
578
579    #[test]
580    fn tree_builder_begin_with_parameters() {
581        let src = "BEGIN;X-PARAM=value:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n";
582        let tree = test_scan_and_build(src);
583
584        // Should still build the tree structure, but with errors
585        assert_eq!(tree.roots.len(), 1);
586        assert_eq!(tree.errors.len(), 1);
587
588        match &tree.errors[0] {
589            TreeBuildError::BeginEndWithParameters { name, .. } => {
590                assert_eq!(name.to_owned(), "BEGIN");
591            }
592            _ => panic!(
593                "Expected BeginEndWithParameters error, got {:?}",
594                tree.errors[0]
595            ),
596        }
597    }
598
599    #[test]
600    fn tree_builder_end_with_parameters() {
601        let src = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND;X-PARAM=value:VCALENDAR\r\n";
602        let tree = test_scan_and_build(src);
603
604        // Should still build the tree structure, but with errors
605        assert_eq!(tree.roots.len(), 1);
606        assert_eq!(tree.errors.len(), 1);
607
608        match &tree.errors[0] {
609            TreeBuildError::BeginEndWithParameters { name, .. } => {
610                assert_eq!(name.to_owned(), "END");
611            }
612            _ => panic!(
613                "Expected BeginEndWithParameters error, got {:?}",
614                tree.errors[0]
615            ),
616        }
617    }
618
619    #[test]
620    fn tree_builder_both_begin_and_end_with_parameters() {
621        let src = "BEGIN;X-PARAM=value:VCALENDAR\r\nVERSION:2.0\r\nEND;X-PARAM=value:VCALENDAR\r\n";
622        let tree = test_scan_and_build(src);
623
624        // Should still build the tree structure, but with errors
625        assert_eq!(tree.roots.len(), 1);
626        assert_eq!(tree.errors.len(), 2);
627
628        // Both should be BeginEndWithParameters errors
629        for error in &tree.errors {
630            match error {
631                TreeBuildError::BeginEndWithParameters { .. } => {}
632                _ => panic!("Expected all errors to be BeginEndWithParameters, got {error:?}"),
633            }
634        }
635    }
636}