Skip to main content

lex_core/lex/ast/elements/
content_item.rs

1//! Content item
2//!
3//! `ContentItem` is the common wrapper for all elements that can
4//! appear in document content. It lets tooling operate uniformly on
5//! mixed structures (paragraphs, sessions, lists, definitions, etc.).
6//!
7//! Examples:
8//! - A session containing paragraphs and a list
9//! - A paragraph followed by a definition and an annotation
10
11use super::super::range::{Position, Range};
12use super::super::traits::{AstNode, Container, Visitor, VisualStructure};
13use super::annotation::Annotation;
14use super::blank_line_group::BlankLineGroup;
15use super::definition::Definition;
16use super::list::{List, ListItem};
17use super::paragraph::{Paragraph, TextLine};
18use super::session::Session;
19use super::verbatim::Verbatim;
20use super::verbatim_line::VerbatimLine;
21use std::fmt;
22
23/// ContentItem represents any element that can appear in document content
24#[derive(Debug, Clone, PartialEq)]
25pub enum ContentItem {
26    Paragraph(Paragraph),
27    Session(Session),
28    List(List),
29    ListItem(ListItem),
30    TextLine(TextLine),
31    Definition(Definition),
32    Annotation(Annotation),
33    VerbatimBlock(Box<Verbatim>),
34    VerbatimLine(VerbatimLine),
35    BlankLineGroup(BlankLineGroup),
36}
37
38impl AstNode for ContentItem {
39    fn node_type(&self) -> &'static str {
40        match self {
41            ContentItem::Paragraph(p) => p.node_type(),
42            ContentItem::Session(s) => s.node_type(),
43            ContentItem::List(l) => l.node_type(),
44            ContentItem::ListItem(li) => li.node_type(),
45            ContentItem::TextLine(tl) => tl.node_type(),
46            ContentItem::Definition(d) => d.node_type(),
47            ContentItem::Annotation(a) => a.node_type(),
48            ContentItem::VerbatimBlock(fb) => fb.node_type(),
49            ContentItem::VerbatimLine(fl) => fl.node_type(),
50            ContentItem::BlankLineGroup(blg) => blg.node_type(),
51        }
52    }
53
54    fn display_label(&self) -> String {
55        match self {
56            ContentItem::Paragraph(p) => p.display_label(),
57            ContentItem::Session(s) => s.display_label(),
58            ContentItem::List(l) => l.display_label(),
59            ContentItem::ListItem(li) => li.display_label(),
60            ContentItem::TextLine(tl) => tl.display_label(),
61            ContentItem::Definition(d) => d.display_label(),
62            ContentItem::Annotation(a) => a.display_label(),
63            ContentItem::VerbatimBlock(fb) => fb.display_label(),
64            ContentItem::VerbatimLine(fl) => fl.display_label(),
65            ContentItem::BlankLineGroup(blg) => blg.display_label(),
66        }
67    }
68
69    fn range(&self) -> &Range {
70        match self {
71            ContentItem::Paragraph(p) => p.range(),
72            ContentItem::Session(s) => s.range(),
73            ContentItem::List(l) => l.range(),
74            ContentItem::ListItem(li) => li.range(),
75            ContentItem::TextLine(tl) => tl.range(),
76            ContentItem::Definition(d) => d.range(),
77            ContentItem::Annotation(a) => a.range(),
78            ContentItem::VerbatimBlock(fb) => fb.range(),
79            ContentItem::VerbatimLine(fl) => fl.range(),
80            ContentItem::BlankLineGroup(blg) => blg.range(),
81        }
82    }
83
84    fn accept(&self, visitor: &mut dyn Visitor) {
85        match self {
86            ContentItem::Paragraph(p) => p.accept(visitor),
87            ContentItem::Session(s) => s.accept(visitor),
88            ContentItem::List(l) => l.accept(visitor),
89            ContentItem::ListItem(li) => li.accept(visitor),
90            ContentItem::TextLine(tl) => tl.accept(visitor),
91            ContentItem::Definition(d) => d.accept(visitor),
92            ContentItem::Annotation(a) => a.accept(visitor),
93            ContentItem::VerbatimBlock(fb) => fb.accept(visitor),
94            ContentItem::VerbatimLine(fl) => fl.accept(visitor),
95            ContentItem::BlankLineGroup(blg) => blg.accept(visitor),
96        }
97    }
98}
99
100impl VisualStructure for ContentItem {
101    fn is_source_line_node(&self) -> bool {
102        match self {
103            ContentItem::Paragraph(p) => p.is_source_line_node(),
104            ContentItem::Session(s) => s.is_source_line_node(),
105            ContentItem::List(l) => l.is_source_line_node(),
106            ContentItem::ListItem(li) => li.is_source_line_node(),
107            ContentItem::TextLine(tl) => tl.is_source_line_node(),
108            ContentItem::Definition(d) => d.is_source_line_node(),
109            ContentItem::Annotation(a) => a.is_source_line_node(),
110            ContentItem::VerbatimBlock(fb) => fb.is_source_line_node(),
111            ContentItem::VerbatimLine(fl) => fl.is_source_line_node(),
112            ContentItem::BlankLineGroup(blg) => blg.is_source_line_node(),
113        }
114    }
115
116    fn has_visual_header(&self) -> bool {
117        match self {
118            ContentItem::Paragraph(p) => p.has_visual_header(),
119            ContentItem::Session(s) => s.has_visual_header(),
120            ContentItem::List(l) => l.has_visual_header(),
121            ContentItem::ListItem(li) => li.has_visual_header(),
122            ContentItem::TextLine(tl) => tl.has_visual_header(),
123            ContentItem::Definition(d) => d.has_visual_header(),
124            ContentItem::Annotation(a) => a.has_visual_header(),
125            ContentItem::VerbatimBlock(fb) => fb.has_visual_header(),
126            ContentItem::VerbatimLine(fl) => fl.has_visual_header(),
127            ContentItem::BlankLineGroup(blg) => blg.has_visual_header(),
128        }
129    }
130
131    fn collapses_with_children(&self) -> bool {
132        match self {
133            ContentItem::Paragraph(p) => p.collapses_with_children(),
134            ContentItem::Session(s) => s.collapses_with_children(),
135            ContentItem::List(l) => l.collapses_with_children(),
136            ContentItem::ListItem(li) => li.collapses_with_children(),
137            ContentItem::TextLine(tl) => tl.collapses_with_children(),
138            ContentItem::Definition(d) => d.collapses_with_children(),
139            ContentItem::Annotation(a) => a.collapses_with_children(),
140            ContentItem::VerbatimBlock(fb) => fb.collapses_with_children(),
141            ContentItem::VerbatimLine(fl) => fl.collapses_with_children(),
142            ContentItem::BlankLineGroup(blg) => blg.collapses_with_children(),
143        }
144    }
145}
146
147impl ContentItem {
148    pub fn label(&self) -> Option<&str> {
149        match self {
150            ContentItem::Session(s) => Some(s.label()),
151            ContentItem::Definition(d) => Some(d.label()),
152            ContentItem::Annotation(a) => Some(a.label()),
153            ContentItem::ListItem(li) => Some(li.label()),
154            ContentItem::VerbatimBlock(fb) => Some(fb.subject.as_string()),
155            _ => None,
156        }
157    }
158
159    pub fn children(&self) -> Option<&[ContentItem]> {
160        match self {
161            ContentItem::Session(s) => Some(&s.children),
162            ContentItem::Definition(d) => Some(&d.children),
163            ContentItem::Annotation(a) => Some(&a.children),
164            ContentItem::List(l) => Some(&l.items),
165            ContentItem::ListItem(li) => Some(&li.children),
166            ContentItem::Paragraph(p) => Some(&p.lines),
167            ContentItem::VerbatimBlock(fb) => Some(&fb.children),
168            ContentItem::TextLine(_) => None,
169            ContentItem::VerbatimLine(_) => None,
170            _ => None,
171        }
172    }
173
174    pub fn children_mut(&mut self) -> Option<&mut Vec<ContentItem>> {
175        match self {
176            ContentItem::Session(s) => Some(s.children.as_mut_vec()),
177            ContentItem::Definition(d) => Some(d.children.as_mut_vec()),
178            ContentItem::Annotation(a) => Some(a.children.as_mut_vec()),
179            ContentItem::List(l) => Some(l.items.as_mut_vec()),
180            ContentItem::ListItem(li) => Some(li.children.as_mut_vec()),
181            ContentItem::Paragraph(p) => Some(&mut p.lines),
182            ContentItem::VerbatimBlock(fb) => Some(fb.children.as_mut_vec()),
183            ContentItem::TextLine(_) => None,
184            ContentItem::VerbatimLine(_) => None,
185            _ => None,
186        }
187    }
188
189    pub fn text(&self) -> Option<String> {
190        match self {
191            ContentItem::Paragraph(p) => Some(p.text()),
192            _ => None,
193        }
194    }
195
196    pub fn is_paragraph(&self) -> bool {
197        matches!(self, ContentItem::Paragraph(_))
198    }
199    pub fn is_session(&self) -> bool {
200        matches!(self, ContentItem::Session(_))
201    }
202    pub fn is_list(&self) -> bool {
203        matches!(self, ContentItem::List(_))
204    }
205    pub fn is_list_item(&self) -> bool {
206        matches!(self, ContentItem::ListItem(_))
207    }
208    pub fn is_text_line(&self) -> bool {
209        matches!(self, ContentItem::TextLine(_))
210    }
211    pub fn is_definition(&self) -> bool {
212        matches!(self, ContentItem::Definition(_))
213    }
214    pub fn is_annotation(&self) -> bool {
215        matches!(self, ContentItem::Annotation(_))
216    }
217    pub fn is_verbatim_block(&self) -> bool {
218        matches!(self, ContentItem::VerbatimBlock(_))
219    }
220
221    pub fn is_verbatim_line(&self) -> bool {
222        matches!(self, ContentItem::VerbatimLine(_))
223    }
224
225    pub fn is_blank_line_group(&self) -> bool {
226        matches!(self, ContentItem::BlankLineGroup(_))
227    }
228
229    pub fn as_paragraph(&self) -> Option<&Paragraph> {
230        if let ContentItem::Paragraph(p) = self {
231            Some(p)
232        } else {
233            None
234        }
235    }
236    pub fn as_session(&self) -> Option<&Session> {
237        if let ContentItem::Session(s) = self {
238            Some(s)
239        } else {
240            None
241        }
242    }
243    pub fn as_list(&self) -> Option<&List> {
244        if let ContentItem::List(l) = self {
245            Some(l)
246        } else {
247            None
248        }
249    }
250    pub fn as_list_item(&self) -> Option<&ListItem> {
251        if let ContentItem::ListItem(li) = self {
252            Some(li)
253        } else {
254            None
255        }
256    }
257    pub fn as_definition(&self) -> Option<&Definition> {
258        if let ContentItem::Definition(d) = self {
259            Some(d)
260        } else {
261            None
262        }
263    }
264    pub fn as_annotation(&self) -> Option<&Annotation> {
265        if let ContentItem::Annotation(a) = self {
266            Some(a)
267        } else {
268            None
269        }
270    }
271    pub fn as_verbatim_block(&self) -> Option<&Verbatim> {
272        if let ContentItem::VerbatimBlock(fb) = self {
273            Some(fb)
274        } else {
275            None
276        }
277    }
278
279    pub fn as_verbatim_line(&self) -> Option<&VerbatimLine> {
280        if let ContentItem::VerbatimLine(fl) = self {
281            Some(fl)
282        } else {
283            None
284        }
285    }
286
287    pub fn as_blank_line_group(&self) -> Option<&BlankLineGroup> {
288        if let ContentItem::BlankLineGroup(blg) = self {
289            Some(blg)
290        } else {
291            None
292        }
293    }
294
295    pub fn as_paragraph_mut(&mut self) -> Option<&mut Paragraph> {
296        if let ContentItem::Paragraph(p) = self {
297            Some(p)
298        } else {
299            None
300        }
301    }
302    pub fn as_session_mut(&mut self) -> Option<&mut Session> {
303        if let ContentItem::Session(s) = self {
304            Some(s)
305        } else {
306            None
307        }
308    }
309    pub fn as_list_mut(&mut self) -> Option<&mut List> {
310        if let ContentItem::List(l) = self {
311            Some(l)
312        } else {
313            None
314        }
315    }
316    pub fn as_list_item_mut(&mut self) -> Option<&mut ListItem> {
317        if let ContentItem::ListItem(li) = self {
318            Some(li)
319        } else {
320            None
321        }
322    }
323    pub fn as_definition_mut(&mut self) -> Option<&mut Definition> {
324        if let ContentItem::Definition(d) = self {
325            Some(d)
326        } else {
327            None
328        }
329    }
330    pub fn as_annotation_mut(&mut self) -> Option<&mut Annotation> {
331        if let ContentItem::Annotation(a) = self {
332            Some(a)
333        } else {
334            None
335        }
336    }
337    pub fn as_verbatim_block_mut(&mut self) -> Option<&mut Verbatim> {
338        if let ContentItem::VerbatimBlock(fb) = self {
339            Some(fb)
340        } else {
341            None
342        }
343    }
344
345    pub fn as_verbatim_line_mut(&mut self) -> Option<&mut VerbatimLine> {
346        if let ContentItem::VerbatimLine(fl) = self {
347            Some(fl)
348        } else {
349            None
350        }
351    }
352
353    pub fn as_blank_line_group_mut(&mut self) -> Option<&mut BlankLineGroup> {
354        if let ContentItem::BlankLineGroup(blg) = self {
355            Some(blg)
356        } else {
357            None
358        }
359    }
360
361    /// Find the deepest element at the given position in this item and its children
362    /// Returns the deepest (most nested) element that contains the position
363    pub fn element_at(&self, pos: Position) -> Option<&ContentItem> {
364        // Check nested items first - even if parent location doesn't contain position,
365        // nested elements might. This is important because parent locations (like sessions)
366        // may only cover their title, not their nested content.
367        if let Some(children) = self.children() {
368            for child in children {
369                if let Some(result) = child.element_at(pos) {
370                    return Some(result); // Return deepest element found
371                }
372            }
373        }
374
375        // Now, check the current item. An item is considered to be at the position if its
376        // location contains the position.
377        // If nested elements were found, they would have been returned above.
378        // If no nested results were found, this item is the deepest element at the position.
379        if self.range().contains(pos) {
380            Some(self)
381        } else {
382            None
383        }
384    }
385
386    /// Find the visual line element at the given position
387    ///
388    /// Returns the element representing a source line (TextLine, ListItem, VerbatimLine,
389    /// BlankLineGroup). For container elements with headers (Session, Definition, Annotation,
390    /// VerbatimBlock), it returns the deepest line element, not the container itself.
391    pub fn visual_line_at(&self, pos: Position) -> Option<&ContentItem> {
392        // First, check children for visual line nodes (depth-first search)
393        if let Some(children) = self.children() {
394            for child in children {
395                if let Some(result) = child.visual_line_at(pos) {
396                    return Some(result);
397                }
398            }
399        }
400
401        // If no children matched, check if this item is a true line-level visual node
402        // (not a container with a header)
403        let is_line_level = matches!(
404            self,
405            ContentItem::TextLine(_)
406                | ContentItem::ListItem(_)
407                | ContentItem::VerbatimLine(_)
408                | ContentItem::BlankLineGroup(_)
409        );
410
411        if is_line_level && self.range().contains(pos) {
412            Some(self)
413        } else {
414            None
415        }
416    }
417
418    /// Find the block element at the given position
419    ///
420    /// Returns the shallowest block-level container element (Session, Definition, List,
421    /// Paragraph, Annotation, VerbatimBlock) that contains the position. This skips
422    /// line-level elements and returns the containing structural block.
423    pub fn block_element_at(&self, pos: Position) -> Option<&ContentItem> {
424        // Check if this is a block element that contains the position
425        let is_block = matches!(
426            self,
427            ContentItem::Session(_)
428                | ContentItem::Definition(_)
429                | ContentItem::List(_)
430                | ContentItem::Paragraph(_)
431                | ContentItem::Annotation(_)
432                | ContentItem::VerbatimBlock(_)
433        );
434
435        if is_block && self.range().contains(pos) {
436            return Some(self);
437        }
438
439        // If not a block element, check children
440        if let Some(children) = self.children() {
441            for child in children {
442                if let Some(result) = child.block_element_at(pos) {
443                    return Some(result);
444                }
445            }
446        }
447
448        None
449    }
450
451    /// Find the path of nodes at the given position, starting from this item
452    /// Returns a vector of nodes [self, child, grandchild, ...]
453    pub fn node_path_at_position(&self, pos: Position) -> Vec<&ContentItem> {
454        // Check nested items first
455        if let Some(children) = self.children() {
456            for child in children {
457                let mut path = child.node_path_at_position(pos);
458                if !path.is_empty() {
459                    path.insert(0, self);
460                    return path;
461                }
462            }
463        }
464
465        // If no children matched, check if this item contains the position
466        if self.range().contains(pos) {
467            vec![self]
468        } else {
469            Vec::new()
470        }
471    }
472
473    /// Recursively iterate all descendants of this node (depth-first pre-order)
474    /// Does not include the node itself, only its descendants
475    pub fn descendants(&self) -> Box<dyn Iterator<Item = &ContentItem> + '_> {
476        if let Some(children) = self.children() {
477            Box::new(
478                children
479                    .iter()
480                    .flat_map(|child| std::iter::once(child).chain(child.descendants())),
481            )
482        } else {
483            Box::new(std::iter::empty())
484        }
485    }
486
487    /// Recursively iterate all descendants with their relative depth
488    /// Depth is relative to this node (direct children have depth 0, their children have depth 1, etc.)
489    pub fn descendants_with_depth(
490        &self,
491        start_depth: usize,
492    ) -> Box<dyn Iterator<Item = (&ContentItem, usize)> + '_> {
493        if let Some(children) = self.children() {
494            Box::new(children.iter().flat_map(move |child| {
495                std::iter::once((child, start_depth))
496                    .chain(child.descendants_with_depth(start_depth + 1))
497            }))
498        } else {
499            Box::new(std::iter::empty())
500        }
501    }
502}
503
504impl fmt::Display for ContentItem {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        match self {
507            ContentItem::Paragraph(p) => write!(f, "Paragraph({} lines)", p.lines.len()),
508            ContentItem::Session(s) => {
509                write!(
510                    f,
511                    "Session('{}', {} items)",
512                    s.title.as_string(),
513                    s.children.len()
514                )
515            }
516            ContentItem::List(l) => write!(f, "List({} items)", l.items.len()),
517            ContentItem::ListItem(li) => {
518                write!(f, "ListItem('{}', {} items)", li.text(), li.children.len())
519            }
520            ContentItem::TextLine(tl) => {
521                write!(f, "TextLine('{}')", tl.text())
522            }
523            ContentItem::Definition(d) => {
524                write!(
525                    f,
526                    "Definition('{}', {} items)",
527                    d.subject.as_string(),
528                    d.children.len()
529                )
530            }
531            ContentItem::Annotation(a) => write!(
532                f,
533                "Annotation('{}', {} params, {} items)",
534                a.data.label.value,
535                a.data.parameters.len(),
536                a.children.len()
537            ),
538            ContentItem::VerbatimBlock(fb) => {
539                write!(f, "VerbatimBlock('{}')", fb.subject.as_string())
540            }
541            ContentItem::VerbatimLine(fl) => {
542                write!(f, "VerbatimLine('{}')", fl.content.as_string())
543            }
544            ContentItem::BlankLineGroup(blg) => write!(f, "{blg}"),
545        }
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::super::super::range::{Position, Range};
552    use super::super::paragraph::Paragraph;
553    use super::*;
554    use crate::lex::ast::elements::typed_content;
555
556    #[test]
557    fn test_element_at_simple_paragraph() {
558        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
559            0..0,
560            Position::new(0, 0),
561            Position::new(0, 4),
562        ));
563        let item = ContentItem::Paragraph(para);
564
565        let pos = Position::new(0, 2);
566        if let Some(result) = item.element_at(pos) {
567            // Should return the deepest element, which is the TextLine
568            assert!(result.is_text_line());
569        } else {
570            panic!("Expected to find element at position");
571        }
572    }
573
574    #[test]
575    fn test_element_at_position_outside_location() {
576        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
577            0..0,
578            Position::new(0, 0),
579            Position::new(0, 4),
580        ));
581        let item = ContentItem::Paragraph(para);
582
583        let pos = Position::new(0, 10);
584        let result = item.element_at(pos);
585        assert!(result.is_none());
586    }
587
588    #[test]
589    fn test_element_at_no_location() {
590        // Item with no location should not match any position
591        let para = Paragraph::from_line("Test".to_string());
592        let item = ContentItem::Paragraph(para);
593
594        let pos = Position::new(5, 10);
595        assert!(item.element_at(pos).is_none());
596    }
597
598    #[test]
599    fn test_element_at_nested_session() {
600        let para = Paragraph::from_line("Nested".to_string()).at(Range::new(
601            0..0,
602            Position::new(1, 0),
603            Position::new(1, 6),
604        ));
605        let session = Session::new(
606            super::super::super::text_content::TextContent::from_string(
607                "Section".to_string(),
608                None,
609            ),
610            typed_content::into_session_contents(vec![ContentItem::Paragraph(para)]),
611        )
612        .at(Range::new(0..0, Position::new(0, 0), Position::new(2, 0)));
613        let item = ContentItem::Session(session);
614
615        let pos = Position::new(1, 3);
616        if let Some(result) = item.element_at(pos) {
617            // Should return the deepest element, which is the TextLine
618            assert!(result.is_text_line());
619        } else {
620            panic!("Expected to find deepest element");
621        }
622    }
623
624    #[test]
625    fn test_descendants_on_session_content_item() {
626        let mut inner_session = Session::with_title("Inner".to_string());
627        inner_session
628            .children
629            .push(ContentItem::Paragraph(Paragraph::from_line(
630                "Grandchild".to_string(),
631            )));
632
633        let mut session = Session::with_title("Outer".to_string());
634        session
635            .children
636            .push(ContentItem::Paragraph(Paragraph::from_line(
637                "Child".to_string(),
638            )));
639        session.children.push(ContentItem::Session(inner_session));
640
641        let item = ContentItem::Session(session);
642        let descendants: Vec<_> = item.descendants().collect();
643        assert_eq!(descendants.len(), 5);
644
645        let paragraphs: Vec<_> = item.descendants().filter(|d| d.is_paragraph()).collect();
646        assert_eq!(paragraphs.len(), 2);
647    }
648
649    #[test]
650    fn element_at_prefers_child_even_if_parent_range_is_tight() {
651        // Session range stops before the child paragraph range, but we should still find the child.
652        let paragraph = Paragraph::from_line("Child".to_string()).at(Range::new(
653            10..15,
654            Position::new(1, 0),
655            Position::new(1, 5),
656        ));
657
658        let mut session = Session::with_title("Header".to_string()).at(Range::new(
659            0..6,
660            Position::new(0, 0),
661            Position::new(0, 6),
662        ));
663        session.children.push(ContentItem::Paragraph(paragraph));
664
665        let pos = Position::new(1, 3);
666        let item = ContentItem::Session(session);
667        let result = item
668            .element_at(pos)
669            .expect("child paragraph should be discoverable");
670
671        assert!(result.is_text_line());
672    }
673
674    #[test]
675    fn descendants_with_depth_tracks_depths() {
676        let paragraph =
677            ContentItem::Paragraph(Paragraph::from_line("Para".to_string()).at(Range::new(
678                0..4,
679                Position::new(0, 0),
680                Position::new(0, 4),
681            )));
682
683        let list_item = ListItem::with_content("-".to_string(), "Item".to_string(), vec![])
684            .at(Range::new(5..9, Position::new(1, 0), Position::new(1, 4)));
685        let list = ContentItem::List(List::new(vec![list_item]));
686
687        let mut session = Session::with_title("Root".to_string());
688        session.children.push(paragraph.clone());
689        session.children.push(list.clone());
690
691        let depths: Vec<(&str, usize)> = ContentItem::Session(session)
692            .descendants_with_depth(0)
693            .map(|(item, depth)| (item.node_type(), depth))
694            .collect();
695
696        assert_eq!(
697            depths,
698            vec![
699                ("Paragraph", 0),
700                ("TextLine", 1),
701                ("List", 0),
702                ("ListItem", 1),
703            ]
704        );
705    }
706
707    #[test]
708    fn test_visual_line_at_finds_text_line() {
709        // Create a paragraph with a text line
710        let para = Paragraph::from_line("Test line".to_string()).at(Range::new(
711            0..9,
712            Position::new(0, 0),
713            Position::new(0, 9),
714        ));
715        let item = ContentItem::Paragraph(para);
716
717        let pos = Position::new(0, 5);
718        let result = item.visual_line_at(pos);
719        assert!(result.is_some());
720        assert!(result.unwrap().is_text_line());
721    }
722
723    #[test]
724    fn test_visual_line_at_finds_list_item() {
725        let list_item = ListItem::with_content("-".to_string(), "Item text".to_string(), vec![])
726            .at(Range::new(0..10, Position::new(0, 0), Position::new(0, 10)));
727        let list = List::new(vec![list_item]).at(Range::new(
728            0..10,
729            Position::new(0, 0),
730            Position::new(0, 10),
731        ));
732        let item = ContentItem::List(list);
733
734        let pos = Position::new(0, 5);
735        let result = item.visual_line_at(pos);
736        assert!(result.is_some());
737        assert!(result.unwrap().is_list_item());
738    }
739
740    #[test]
741    fn test_visual_line_at_position_outside() {
742        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
743            0..4,
744            Position::new(0, 0),
745            Position::new(0, 4),
746        ));
747        let item = ContentItem::Paragraph(para);
748
749        let pos = Position::new(10, 10);
750        let result = item.visual_line_at(pos);
751        assert!(result.is_none());
752    }
753
754    #[test]
755    fn test_block_element_at_finds_paragraph() {
756        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
757            0..4,
758            Position::new(0, 0),
759            Position::new(0, 4),
760        ));
761        let item = ContentItem::Paragraph(para);
762
763        let pos = Position::new(0, 2);
764        let result = item.block_element_at(pos);
765        assert!(result.is_some());
766        assert!(result.unwrap().is_paragraph());
767    }
768
769    #[test]
770    fn test_block_element_at_finds_session() {
771        let mut session = Session::with_title("Section".to_string()).at(Range::new(
772            0..10,
773            Position::new(0, 0),
774            Position::new(2, 0),
775        ));
776        session
777            .children
778            .push(ContentItem::Paragraph(Paragraph::from_line(
779                "Content".to_string(),
780            )));
781        let item = ContentItem::Session(session);
782
783        let pos = Position::new(1, 0);
784        let result = item.block_element_at(pos);
785        assert!(result.is_some());
786        // Should find the Session, not descend into nested elements
787        assert!(result.unwrap().is_session());
788    }
789
790    #[test]
791    fn test_block_element_at_skips_text_line() {
792        // When called on a nested structure with TextLine, should return the Paragraph
793        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
794            0..4,
795            Position::new(0, 0),
796            Position::new(0, 4),
797        ));
798
799        let mut session = Session::with_title("Section".to_string()).at(Range::new(
800            0..10,
801            Position::new(0, 0),
802            Position::new(2, 0),
803        ));
804        session.children.push(ContentItem::Paragraph(para));
805        let item = ContentItem::Session(session);
806
807        let pos = Position::new(0, 2);
808        let result = item.block_element_at(pos);
809        assert!(result.is_some());
810        // Should return Session, the first block element encountered
811        assert!(result.unwrap().is_session());
812    }
813
814    #[test]
815    fn test_block_element_at_position_outside() {
816        let para = Paragraph::from_line("Test".to_string()).at(Range::new(
817            0..4,
818            Position::new(0, 0),
819            Position::new(0, 4),
820        ));
821        let item = ContentItem::Paragraph(para);
822
823        let pos = Position::new(10, 10);
824        let result = item.block_element_at(pos);
825        assert!(result.is_none());
826    }
827
828    #[test]
829    fn test_comparison_element_at_vs_visual_line_at_vs_block_element_at() {
830        // Build a structure: Session > Paragraph > TextLine
831        let para = Paragraph::from_line("Test line".to_string()).at(Range::new(
832            5..14,
833            Position::new(1, 0),
834            Position::new(1, 9),
835        ));
836
837        let mut session = Session::with_title("Title".to_string()).at(Range::new(
838            0..14,
839            Position::new(0, 0),
840            Position::new(1, 9),
841        ));
842        session.children.push(ContentItem::Paragraph(para));
843        let item = ContentItem::Session(session);
844
845        let pos = Position::new(1, 5);
846
847        // element_at should return the deepest element (TextLine)
848        let deepest = item.element_at(pos);
849        assert!(deepest.is_some());
850        assert!(deepest.unwrap().is_text_line());
851
852        // visual_line_at should also return TextLine (it's a visual line node)
853        let visual = item.visual_line_at(pos);
854        assert!(
855            visual.is_some(),
856            "visual_line_at should find a visual line element"
857        );
858        let visual_item = visual.unwrap();
859        assert!(
860            visual_item.is_text_line(),
861            "Expected TextLine but got: {:?}",
862            visual_item.node_type()
863        );
864
865        // block_element_at should return the Session (first block element)
866        let block = item.block_element_at(pos);
867        assert!(block.is_some());
868        assert!(block.unwrap().is_session());
869    }
870}