Skip to main content

lex_core/lex/ast/elements/
session.rs

1//! Session element
2//!
3//!     A session is the main structural element of lex documents. Sessions can be arbitrarily nested
4//!     and contain required titles and content.
5//!
6//!     Sessions establish hierarchy within a document via their title and nested content, like all
7//!     major elements in lex. The structure of the document is a tree of sessions, which can be
8//!     nested arbitrarily. This creates powerful addressing capabilities as one can target any
9//!     sub-session from an index.
10//!
11//! Structure:
12//!
13//!         - Title: short text identifying the session
14//!         - Content: any elements allowed in the body (including other sessions for unlimited nesting)
15//!
16//!     The title can be any text content, and is often decorated with an ordering indicator, just
17//!     like lists, and in lex all the numerical, alphabetical, and roman numeral indicators are
18//!     supported.
19//!
20//! Parsing Rules
21//!
22//! Sessions follow this parsing pattern:
23//!
24//! | Element | Prec. Blank | Head          | Blank | Content | Tail   |
25//! |---------|-------------|---------------|-------|---------|--------|
26//! | Session | Yes         | ParagraphLine | Yes   | Yes     | dedent |
27//!
28//!     Sessions are unique in that the head must be enclosed by blank lines (both preceding and
29//!     following). The reason this is significant is that it makes for a lot of complication in
30//!     specific scenarios.
31//!
32//!     Consider the parsing of a session that is the very first element of its parent session. As
33//!     it's the very first element, the preceding blank line is part of its parent session. It can
34//!     see the following blank line before the paragraph just fine, as it belongs to it. But the
35//!     first blank line is out of its reach.
36//!
37//!     The obvious solution would be to imperatively walk the tree up and check if the parent
38//!     session has a preceding blank line. This works but this makes the grammar context sensitive,
39//!     and now things are way more complicated, goodbye simple regular language parser.
40//!
41//!     The way this is handled is that we inject a synthetic token that represents the preceding
42//!     blank line. This token is not produced by the logos lexer, but is created by the lexing
43//!     pipeline to capture context information from parent to children elements so that parsing can
44//!     be done in a regular single pass. As expected, this token is not consumed nor becomes a
45//!     blank line node, but it's only used to decide on the parsing of the child elements.
46//!
47//!     For more details on how sessions fit into the AST structure and indentation model, see
48//!     the [elements](crate::lex::ast::elements) module.
49//!
50//! Examples:
51//!
52//! Welcome to The Lex format
53//!
54//!     Lex is a plain text document format. ...
55//!
56//! 1.4 The Finale
57//!
58//!     Here is where we stop.
59//!
60use super::super::range::{Position, Range};
61use super::super::text_content::TextContent;
62use super::super::traits::{AstNode, Container, Visitor, VisualStructure};
63use super::annotation::Annotation;
64use super::container::SessionContainer;
65use super::content_item::ContentItem;
66use super::definition::Definition;
67use super::list::{List, ListItem};
68use super::paragraph::Paragraph;
69use super::table::Table;
70use super::typed_content::SessionContent;
71use super::verbatim::Verbatim;
72use std::fmt;
73
74/// A session represents a hierarchical container with a title
75#[derive(Debug, Clone, PartialEq)]
76pub struct Session {
77    pub title: TextContent,
78    pub marker: Option<super::sequence_marker::SequenceMarker>,
79    pub children: SessionContainer,
80    pub annotations: Vec<Annotation>,
81    pub location: Range,
82}
83
84impl Session {
85    fn default_location() -> Range {
86        Range::new(0..0, Position::new(0, 0), Position::new(0, 0))
87    }
88    pub fn new(title: TextContent, children: Vec<SessionContent>) -> Self {
89        Self {
90            title,
91            marker: None,
92            children: SessionContainer::from_typed(children),
93            annotations: Vec::new(),
94            location: Self::default_location(),
95        }
96    }
97    pub fn with_title(title: String) -> Self {
98        Self {
99            title: TextContent::from_string(title, None),
100            marker: None,
101            children: SessionContainer::empty(),
102            annotations: Vec::new(),
103            location: Self::default_location(),
104        }
105    }
106
107    /// Preferred builder
108    pub fn at(mut self, location: Range) -> Self {
109        self.location = location;
110        self
111    }
112
113    /// Annotations attached to this session header/content block.
114    pub fn annotations(&self) -> &[Annotation] {
115        &self.annotations
116    }
117
118    /// Range covering only the session title line, if available.
119    pub fn header_location(&self) -> Option<&Range> {
120        self.title.location.as_ref()
121    }
122
123    /// Bounding range covering only the session's children.
124    pub fn body_location(&self) -> Option<Range> {
125        Range::bounding_box(self.children.iter().map(|item| item.range()))
126    }
127
128    /// Get the title text without the sequence marker
129    ///
130    /// Returns the title with any leading sequence marker removed.
131    /// For example, "1. Introduction" becomes "Introduction".
132    ///
133    /// # Examples
134    ///
135    /// ```rust,ignore
136    /// let session = parse_session("1. Introduction:\n\n    Content");
137    /// assert_eq!(session.title_text(), "Introduction");
138    /// ```
139    pub fn title_text(&self) -> &str {
140        if let Some(marker) = &self.marker {
141            let full_title = self.title.as_string();
142            let marker_text = marker.as_str();
143
144            // Find where the marker ends in the title
145            if let Some(pos) = full_title.find(marker_text) {
146                // Skip the marker and any whitespace after it
147                let after_marker = &full_title[pos + marker_text.len()..];
148                return after_marker.trim_start();
149            }
150        }
151
152        // No marker, return the full title
153        self.title.as_string()
154    }
155
156    /// Get the full title including any sequence marker
157    ///
158    /// Returns the complete title as it appears in the source, including
159    /// any sequence marker prefix.
160    ///
161    /// # Examples
162    ///
163    /// ```rust,ignore
164    /// let session = parse_session("1. Introduction:\n\n    Content");
165    /// assert_eq!(session.full_title(), "1. Introduction");
166    /// ```
167    pub fn full_title(&self) -> &str {
168        self.title.as_string()
169    }
170
171    /// Mutable access to session annotations.
172    pub fn annotations_mut(&mut self) -> &mut Vec<Annotation> {
173        &mut self.annotations
174    }
175
176    /// Iterate over annotation blocks in source order.
177    pub fn iter_annotations(&self) -> std::slice::Iter<'_, Annotation> {
178        self.annotations.iter()
179    }
180
181    /// Iterate over all content items nested inside attached annotations.
182    pub fn iter_annotation_contents(&self) -> impl Iterator<Item = &ContentItem> {
183        self.annotations
184            .iter()
185            .flat_map(|annotation| annotation.children())
186    }
187
188    // ========================================================================
189    // DELEGATION TO CONTAINER
190    // All query/traversal methods delegate to the rich container implementation
191    // ========================================================================
192
193    /// Iterate over immediate content items
194    pub fn iter_items(&self) -> impl Iterator<Item = &ContentItem> {
195        self.children.iter()
196    }
197
198    /// Iterate over immediate paragraph children
199    pub fn iter_paragraphs(&self) -> impl Iterator<Item = &Paragraph> {
200        self.children.iter_paragraphs()
201    }
202
203    /// Iterate over immediate session children
204    pub fn iter_sessions(&self) -> impl Iterator<Item = &Session> {
205        self.children.iter_sessions()
206    }
207
208    /// Iterate over immediate list children
209    pub fn iter_lists(&self) -> impl Iterator<Item = &List> {
210        self.children.iter_lists()
211    }
212
213    /// Iterate over immediate verbatim block children
214    pub fn iter_verbatim_blocks(&self) -> impl Iterator<Item = &Verbatim> {
215        self.children.iter_verbatim_blocks()
216    }
217
218    /// Iterate all nodes in the session tree (depth-first pre-order traversal)
219    pub fn iter_all_nodes(&self) -> Box<dyn Iterator<Item = &ContentItem> + '_> {
220        self.children.iter_all_nodes()
221    }
222
223    /// Iterate all nodes with their depth (0 = immediate children)
224    pub fn iter_all_nodes_with_depth(
225        &self,
226    ) -> Box<dyn Iterator<Item = (&ContentItem, usize)> + '_> {
227        self.children.iter_all_nodes_with_depth()
228    }
229
230    /// Recursively iterate all paragraphs at any depth
231    pub fn iter_paragraphs_recursive(&self) -> Box<dyn Iterator<Item = &Paragraph> + '_> {
232        self.children.iter_paragraphs_recursive()
233    }
234
235    /// Recursively iterate all sessions at any depth
236    pub fn iter_sessions_recursive(&self) -> Box<dyn Iterator<Item = &Session> + '_> {
237        self.children.iter_sessions_recursive()
238    }
239
240    /// Recursively iterate all lists at any depth
241    pub fn iter_lists_recursive(&self) -> Box<dyn Iterator<Item = &List> + '_> {
242        self.children.iter_lists_recursive()
243    }
244
245    /// Recursively iterate all verbatim blocks at any depth
246    pub fn iter_verbatim_blocks_recursive(&self) -> Box<dyn Iterator<Item = &Verbatim> + '_> {
247        self.children.iter_verbatim_blocks_recursive()
248    }
249
250    /// Recursively iterate all list items at any depth
251    pub fn iter_list_items_recursive(&self) -> Box<dyn Iterator<Item = &ListItem> + '_> {
252        self.children.iter_list_items_recursive()
253    }
254
255    /// Recursively iterate all definitions at any depth
256    pub fn iter_definitions_recursive(&self) -> Box<dyn Iterator<Item = &Definition> + '_> {
257        self.children.iter_definitions_recursive()
258    }
259
260    /// Recursively iterate all annotations at any depth
261    pub fn iter_annotations_recursive(&self) -> Box<dyn Iterator<Item = &Annotation> + '_> {
262        self.children.iter_annotations_recursive()
263    }
264
265    /// Get the first paragraph (returns None if not found)
266    pub fn first_paragraph(&self) -> Option<&Paragraph> {
267        self.children.first_paragraph()
268    }
269
270    /// Get the first session (returns None if not found)
271    pub fn first_session(&self) -> Option<&Session> {
272        self.children.first_session()
273    }
274
275    /// Get the first list (returns None if not found)
276    pub fn first_list(&self) -> Option<&List> {
277        self.children.first_list()
278    }
279
280    /// Get the first definition (returns None if not found)
281    pub fn first_definition(&self) -> Option<&Definition> {
282        self.children.first_definition()
283    }
284
285    /// Get the first annotation (returns None if not found)
286    pub fn first_annotation(&self) -> Option<&Annotation> {
287        self.children.first_annotation()
288    }
289
290    /// Get the first verbatim block (returns None if not found)
291    pub fn first_verbatim(&self) -> Option<&Verbatim> {
292        self.children.first_verbatim()
293    }
294
295    /// Get the first paragraph, panicking if none found
296    pub fn expect_paragraph(&self) -> &Paragraph {
297        self.children.expect_paragraph()
298    }
299
300    /// Get the first session, panicking if none found
301    pub fn expect_session(&self) -> &Session {
302        self.children.expect_session()
303    }
304
305    /// Get the first list, panicking if none found
306    pub fn expect_list(&self) -> &List {
307        self.children.expect_list()
308    }
309
310    /// Get the first definition, panicking if none found
311    pub fn expect_definition(&self) -> &Definition {
312        self.children.expect_definition()
313    }
314
315    /// Get the first annotation, panicking if none found
316    pub fn expect_annotation(&self) -> &Annotation {
317        self.children.expect_annotation()
318    }
319
320    /// Get the first verbatim block, panicking if none found
321    pub fn expect_verbatim(&self) -> &Verbatim {
322        self.children.expect_verbatim()
323    }
324
325    /// Get the first table, panicking if none found
326    pub fn expect_table(&self) -> &Table {
327        self.children.expect_table()
328    }
329
330    /// Find all paragraphs matching a predicate
331    pub fn find_paragraphs<F>(&self, predicate: F) -> Vec<&Paragraph>
332    where
333        F: Fn(&Paragraph) -> bool,
334    {
335        self.children.find_paragraphs(predicate)
336    }
337
338    /// Find all sessions matching a predicate
339    pub fn find_sessions<F>(&self, predicate: F) -> Vec<&Session>
340    where
341        F: Fn(&Session) -> bool,
342    {
343        self.children.find_sessions(predicate)
344    }
345
346    /// Find all lists matching a predicate
347    pub fn find_lists<F>(&self, predicate: F) -> Vec<&List>
348    where
349        F: Fn(&List) -> bool,
350    {
351        self.children.find_lists(predicate)
352    }
353
354    /// Find all definitions matching a predicate
355    pub fn find_definitions<F>(&self, predicate: F) -> Vec<&Definition>
356    where
357        F: Fn(&Definition) -> bool,
358    {
359        self.children.find_definitions(predicate)
360    }
361
362    /// Find all annotations matching a predicate
363    pub fn find_annotations<F>(&self, predicate: F) -> Vec<&Annotation>
364    where
365        F: Fn(&Annotation) -> bool,
366    {
367        self.children.find_annotations(predicate)
368    }
369
370    /// Find all nodes matching a generic predicate
371    pub fn find_nodes<F>(&self, predicate: F) -> Vec<&ContentItem>
372    where
373        F: Fn(&ContentItem) -> bool,
374    {
375        self.children.find_nodes(predicate)
376    }
377
378    /// Find all nodes at a specific depth
379    pub fn find_nodes_at_depth(&self, target_depth: usize) -> Vec<&ContentItem> {
380        self.children.find_nodes_at_depth(target_depth)
381    }
382
383    /// Find all nodes within a depth range
384    pub fn find_nodes_in_depth_range(
385        &self,
386        min_depth: usize,
387        max_depth: usize,
388    ) -> Vec<&ContentItem> {
389        self.children
390            .find_nodes_in_depth_range(min_depth, max_depth)
391    }
392
393    /// Find nodes at a specific depth matching a predicate
394    pub fn find_nodes_with_depth<F>(&self, target_depth: usize, predicate: F) -> Vec<&ContentItem>
395    where
396        F: Fn(&ContentItem) -> bool,
397    {
398        self.children.find_nodes_with_depth(target_depth, predicate)
399    }
400
401    /// Count immediate children by type
402    pub fn count_by_type(&self) -> (usize, usize, usize, usize) {
403        self.children.count_by_type()
404    }
405
406    /// Returns the deepest (most nested) element that contains the position
407    pub fn element_at(&self, pos: Position) -> Option<&ContentItem> {
408        self.children.element_at(pos)
409    }
410
411    /// Returns the visual line element at the given position
412    pub fn visual_line_at(&self, pos: Position) -> Option<&ContentItem> {
413        self.children.visual_line_at(pos)
414    }
415
416    /// Returns the block element at the given position
417    pub fn block_element_at(&self, pos: Position) -> Option<&ContentItem> {
418        self.children.block_element_at(pos)
419    }
420
421    /// Returns the deepest AST node at the given position, if any
422    pub fn find_nodes_at_position(&self, position: Position) -> Vec<&dyn AstNode> {
423        self.children.find_nodes_at_position(position)
424    }
425
426    /// Returns the path of nodes at the given position, starting from this session
427    pub fn node_path_at_position(&self, pos: Position) -> Vec<&dyn AstNode> {
428        let path = self.children.node_path_at_position(pos);
429        if !path.is_empty() {
430            let mut nodes: Vec<&dyn AstNode> = Vec::with_capacity(path.len() + 1);
431            nodes.push(self);
432            for item in path {
433                nodes.push(item);
434            }
435            nodes
436        } else if self.location.contains(pos) {
437            vec![self]
438        } else {
439            Vec::new()
440        }
441    }
442
443    /// Formats information about nodes located at a given position
444    pub fn format_at_position(&self, position: Position) -> String {
445        self.children.format_at_position(position)
446    }
447
448    // ========================================================================
449    // REFERENCE RESOLUTION APIs (Issue #291)
450    // ========================================================================
451
452    /// Find the first annotation with a matching label.
453    ///
454    /// This searches recursively through all annotations in the session tree.
455    ///
456    /// # Arguments
457    /// * `label` - The label string to search for
458    ///
459    /// # Returns
460    /// The first annotation whose label matches exactly, or None if not found.
461    ///
462    /// # Example
463    /// ```rust,ignore
464    /// // Find annotation with label "42" for reference [42]
465    /// if let Some(annotation) = session.find_annotation_by_label("42") {
466    ///     // Jump to this annotation in go-to-definition
467    /// }
468    /// ```
469    pub fn find_annotation_by_label(&self, label: &str) -> Option<&Annotation> {
470        self.iter_annotations_recursive()
471            .find(|ann| ann.data.label.value == label)
472    }
473
474    /// Find all annotations with a matching label.
475    ///
476    /// This searches recursively through all annotations in the session tree.
477    /// Multiple annotations might share the same label.
478    ///
479    /// # Arguments
480    /// * `label` - The label string to search for
481    ///
482    /// # Returns
483    /// A vector of all annotations whose labels match exactly.
484    ///
485    /// # Example
486    /// ```rust,ignore
487    /// // Find all annotations labeled "note"
488    /// let notes = session.find_annotations_by_label("note");
489    /// for note in notes {
490    ///     // Process each note annotation
491    /// }
492    /// ```
493    pub fn find_annotations_by_label(&self, label: &str) -> Vec<&Annotation> {
494        self.iter_annotations_recursive()
495            .filter(|ann| ann.data.label.value == label)
496            .collect()
497    }
498
499    /// Iterate all inline references at any depth.
500    ///
501    /// This method recursively walks the session tree, parses inline content,
502    /// and yields all reference inline nodes (e.g., \[42\], \[@citation\], \[^note\]).
503    ///
504    /// Note: This method does not currently return source ranges for individual
505    /// references. Use the paragraph's location as a starting point for finding
506    /// references in the source.
507    ///
508    /// # Returns
509    /// An iterator of references to ReferenceInline nodes
510    ///
511    /// # Example
512    /// ```rust,ignore
513    /// for reference in session.iter_all_references() {
514    ///     match &reference.reference_type {
515    ///         ReferenceType::FootnoteNumber { number } => {
516    ///             // Find annotation with this number
517    ///         }
518    ///         ReferenceType::Citation(data) => {
519    ///             // Process citation
520    ///         }
521    ///         _ => {}
522    ///     }
523    /// }
524    /// ```
525    pub fn iter_all_references(
526        &self,
527    ) -> Box<dyn Iterator<Item = crate::lex::inlines::ReferenceInline> + '_> {
528        use crate::lex::inlines::InlineNode;
529
530        // Helper to extract refs from TextContent
531        let extract_refs = |text_content: &TextContent| {
532            let inlines = text_content.inline_items();
533            inlines
534                .into_iter()
535                .filter_map(|node| {
536                    if let InlineNode::Reference { data, .. } = node {
537                        Some(data)
538                    } else {
539                        None
540                    }
541                })
542                .collect::<Vec<_>>()
543        };
544
545        // Title refs
546        let title_refs = extract_refs(&self.title);
547
548        // Collect all paragraphs recursively
549        let paragraphs: Vec<_> = self.iter_paragraphs_recursive().collect();
550
551        // For each paragraph, iterate through lines and collect references
552        let para_refs: Vec<_> = paragraphs
553            .into_iter()
554            .flat_map(|para| {
555                // Iterate through each text line in the paragraph
556                para.lines
557                    .iter()
558                    .filter_map(|item| {
559                        if let super::content_item::ContentItem::TextLine(text_line) = item {
560                            Some(&text_line.content)
561                        } else {
562                            None
563                        }
564                    })
565                    .flat_map(extract_refs)
566                    .collect::<Vec<_>>()
567            })
568            .collect();
569
570        Box::new(title_refs.into_iter().chain(para_refs))
571    }
572
573    /// Find all references to a specific target label.
574    ///
575    /// This method searches for inline references that point to the given target.
576    /// For example, find all `[42]` references when looking for footnote "42".
577    ///
578    /// # Arguments
579    /// * `target` - The target label to search for
580    ///
581    /// # Returns
582    /// A vector of references to ReferenceInline nodes that match the target
583    ///
584    /// # Example
585    /// ```rust,ignore
586    /// // Find all references to footnote "42"
587    /// let refs = session.find_references_to("42");
588    /// println!("Found {} references to footnote 42", refs.len());
589    /// ```
590    pub fn find_references_to(&self, target: &str) -> Vec<crate::lex::inlines::ReferenceInline> {
591        use crate::lex::inlines::ReferenceType;
592
593        self.iter_all_references()
594            .filter(|reference| match &reference.reference_type {
595                ReferenceType::FootnoteNumber { number } => target == number.to_string(),
596                ReferenceType::FootnoteLabeled { label } => target == label,
597                ReferenceType::Session { target: ref_target } => target == ref_target,
598                ReferenceType::General { target: ref_target } => target == ref_target,
599                ReferenceType::Citation(data) => data.keys.iter().any(|key| key == target),
600                _ => false,
601            })
602            .collect()
603    }
604}
605
606impl AstNode for Session {
607    fn node_type(&self) -> &'static str {
608        "Session"
609    }
610    fn display_label(&self) -> String {
611        self.title.as_string().to_string()
612    }
613    fn range(&self) -> &Range {
614        &self.location
615    }
616
617    fn accept(&self, visitor: &mut dyn Visitor) {
618        visitor.visit_session(self);
619        super::super::traits::visit_children(visitor, &self.children);
620        visitor.leave_session(self);
621    }
622}
623
624impl VisualStructure for Session {
625    fn is_source_line_node(&self) -> bool {
626        true
627    }
628
629    fn has_visual_header(&self) -> bool {
630        true
631    }
632}
633
634impl Container for Session {
635    fn label(&self) -> &str {
636        self.title.as_string()
637    }
638    fn children(&self) -> &[ContentItem] {
639        &self.children
640    }
641    fn children_mut(&mut self) -> &mut Vec<ContentItem> {
642        self.children.as_mut_vec()
643    }
644}
645
646impl fmt::Display for Session {
647    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
648        write!(
649            f,
650            "Session('{}', {} items)",
651            self.title.as_string(),
652            self.children.len()
653        )
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::super::paragraph::Paragraph;
660    use super::*;
661
662    // ========================================================================
663    // SESSION-SPECIFIC TESTS
664    // Container query/traversal functionality is tested in container.rs
665    // These tests focus on session-specific behavior only
666    // ========================================================================
667
668    #[test]
669    fn test_session_creation() {
670        let mut session = Session::with_title("Introduction".to_string());
671        session
672            .children_mut()
673            .push(ContentItem::Paragraph(Paragraph::from_line(
674                "Content".to_string(),
675            )));
676        assert_eq!(session.label(), "Introduction");
677        assert_eq!(session.children.len(), 1);
678    }
679
680    #[test]
681    fn test_session_location_builder() {
682        let location = super::super::super::range::Range::new(
683            0..0,
684            super::super::super::range::Position::new(1, 0),
685            super::super::super::range::Position::new(1, 10),
686        );
687        let session = Session::with_title("Title".to_string()).at(location.clone());
688        assert_eq!(session.location, location);
689    }
690
691    #[test]
692    fn test_session_header_and_body_locations() {
693        let title_range = Range::new(0..5, Position::new(0, 0), Position::new(0, 5));
694        let child_range = Range::new(10..20, Position::new(1, 0), Position::new(2, 0));
695        let title = TextContent::from_string("Title".to_string(), Some(title_range.clone()));
696        let child = Paragraph::from_line("Child".to_string()).at(child_range.clone());
697        let child_item = ContentItem::Paragraph(child);
698        let session = Session::new(title, vec![SessionContent::from(child_item)]).at(Range::new(
699            0..25,
700            Position::new(0, 0),
701            Position::new(2, 0),
702        ));
703
704        assert_eq!(session.header_location(), Some(&title_range));
705        assert_eq!(session.body_location().unwrap().span, child_range.span);
706    }
707
708    #[test]
709    fn test_session_annotations() {
710        let mut session = Session::with_title("Test".to_string());
711        assert_eq!(session.annotations().len(), 0);
712
713        session
714            .annotations_mut()
715            .push(Annotation::marker(super::super::label::Label::new(
716                "test".to_string(),
717            )));
718        assert_eq!(session.annotations().len(), 1);
719        assert_eq!(session.iter_annotations().count(), 1);
720    }
721
722    #[test]
723    fn test_session_delegation_to_container() {
724        // Smoke test to verify delegation works
725        let mut session = Session::with_title("Root".to_string());
726        session
727            .children
728            .push(ContentItem::Paragraph(Paragraph::from_line(
729                "Para 1".to_string(),
730            )));
731        session
732            .children
733            .push(ContentItem::Paragraph(Paragraph::from_line(
734                "Para 2".to_string(),
735            )));
736
737        // Verify delegation methods work
738        assert_eq!(session.iter_paragraphs().count(), 2);
739        assert_eq!(session.first_paragraph().unwrap().text(), "Para 1");
740        assert_eq!(session.count_by_type(), (2, 0, 0, 0));
741    }
742
743    mod sequence_marker_integration {
744        use super::*;
745        use crate::lex::ast::elements::{DecorationStyle, Form, Separator};
746        use crate::lex::loader::DocumentLoader;
747
748        #[test]
749        fn parse_extracts_numerical_period_marker() {
750            let source = "1. First Session:\n\n    Content here";
751            let doc = DocumentLoader::from_string(source)
752                .parse()
753                .expect("parse failed");
754
755            let session = doc
756                .root
757                .children
758                .get(0)
759                .and_then(|item| {
760                    if let ContentItem::Session(session) = item {
761                        Some(session)
762                    } else {
763                        None
764                    }
765                })
766                .expect("expected session");
767
768            assert!(session.marker.is_some());
769            let marker = session.marker.as_ref().unwrap();
770            assert_eq!(marker.style, DecorationStyle::Numerical);
771            assert_eq!(marker.separator, Separator::Period);
772            assert_eq!(marker.form, Form::Short);
773            assert_eq!(marker.raw_text.as_string(), "1.");
774        }
775
776        #[test]
777        fn parse_extracts_numerical_paren_marker() {
778            let source = "1) Second Session:\n\n    Content here";
779            let doc = DocumentLoader::from_string(source)
780                .parse()
781                .expect("parse failed");
782
783            let session = doc
784                .root
785                .children
786                .get(0)
787                .and_then(|item| {
788                    if let ContentItem::Session(session) = item {
789                        Some(session)
790                    } else {
791                        None
792                    }
793                })
794                .expect("expected session");
795
796            assert!(session.marker.is_some());
797            let marker = session.marker.as_ref().unwrap();
798            assert_eq!(marker.style, DecorationStyle::Numerical);
799            assert_eq!(marker.separator, Separator::Parenthesis);
800            assert_eq!(marker.form, Form::Short);
801            assert_eq!(marker.raw_text.as_string(), "1)");
802        }
803
804        #[test]
805        fn parse_extracts_alphabetical_marker() {
806            let source = "a. Alpha Session:\n\n    Content here";
807            let doc = DocumentLoader::from_string(source)
808                .parse()
809                .expect("parse failed");
810
811            let session = doc
812                .root
813                .children
814                .get(0)
815                .and_then(|item| {
816                    if let ContentItem::Session(session) = item {
817                        Some(session)
818                    } else {
819                        None
820                    }
821                })
822                .expect("expected session");
823
824            assert!(session.marker.is_some());
825            let marker = session.marker.as_ref().unwrap();
826            assert_eq!(marker.style, DecorationStyle::Alphabetical);
827            assert_eq!(marker.separator, Separator::Period);
828            assert_eq!(marker.form, Form::Short);
829            assert_eq!(marker.raw_text.as_string(), "a.");
830        }
831
832        #[test]
833        fn parse_extracts_roman_marker() {
834            let source = "I. Roman Session:\n\n    Content here";
835            let doc = DocumentLoader::from_string(source)
836                .parse()
837                .expect("parse failed");
838
839            let session = doc
840                .root
841                .children
842                .get(0)
843                .and_then(|item| {
844                    if let ContentItem::Session(session) = item {
845                        Some(session)
846                    } else {
847                        None
848                    }
849                })
850                .expect("expected session");
851
852            assert!(session.marker.is_some());
853            let marker = session.marker.as_ref().unwrap();
854            assert_eq!(marker.style, DecorationStyle::Roman);
855            assert_eq!(marker.separator, Separator::Period);
856            assert_eq!(marker.form, Form::Short);
857            assert_eq!(marker.raw_text.as_string(), "I.");
858        }
859
860        #[test]
861        fn parse_extracts_extended_numerical_marker() {
862            let source = "1.2.3 Extended Session:\n\n    Content here";
863            let doc = DocumentLoader::from_string(source)
864                .parse()
865                .expect("parse failed");
866
867            let session = doc
868                .root
869                .children
870                .get(0)
871                .and_then(|item| {
872                    if let ContentItem::Session(session) = item {
873                        Some(session)
874                    } else {
875                        None
876                    }
877                })
878                .expect("expected session");
879
880            assert!(session.marker.is_some());
881            let marker = session.marker.as_ref().unwrap();
882            assert_eq!(marker.style, DecorationStyle::Numerical);
883            assert_eq!(marker.separator, Separator::Period);
884            assert_eq!(marker.form, Form::Extended);
885            assert_eq!(marker.raw_text.as_string(), "1.2.3");
886        }
887
888        #[test]
889        fn parse_extracts_double_paren_marker() {
890            let source = "(1) Parens Session:\n\n    Content here";
891            let doc = DocumentLoader::from_string(source)
892                .parse()
893                .expect("parse failed");
894
895            let session = doc
896                .root
897                .children
898                .get(0)
899                .and_then(|item| {
900                    if let ContentItem::Session(session) = item {
901                        Some(session)
902                    } else {
903                        None
904                    }
905                })
906                .expect("expected session");
907
908            assert!(session.marker.is_some());
909            let marker = session.marker.as_ref().unwrap();
910            assert_eq!(marker.style, DecorationStyle::Numerical);
911            assert_eq!(marker.separator, Separator::DoubleParens);
912            assert_eq!(marker.form, Form::Short);
913            assert_eq!(marker.raw_text.as_string(), "(1)");
914        }
915
916        #[test]
917        fn session_without_marker_has_none() {
918            let source = "Plain Session:\n\n    Content here";
919            let doc = DocumentLoader::from_string(source)
920                .parse()
921                .expect("parse failed");
922
923            let session = doc
924                .root
925                .children
926                .get(0)
927                .and_then(|item| {
928                    if let ContentItem::Session(session) = item {
929                        Some(session)
930                    } else {
931                        None
932                    }
933                })
934                .expect("expected session");
935
936            assert!(session.marker.is_none());
937        }
938
939        #[test]
940        fn title_text_excludes_marker() {
941            let source = "1. Introduction:\n\n    Content here";
942            let doc = DocumentLoader::from_string(source)
943                .parse()
944                .expect("parse failed");
945
946            let session = doc
947                .root
948                .children
949                .get(0)
950                .and_then(|item| {
951                    if let ContentItem::Session(session) = item {
952                        Some(session)
953                    } else {
954                        None
955                    }
956                })
957                .expect("expected session");
958
959            // The title includes the colon, but the marker is stripped
960            assert_eq!(session.title_text(), "Introduction:");
961            assert_eq!(session.full_title(), "1. Introduction:");
962        }
963
964        #[test]
965        fn title_text_without_marker_returns_full_title() {
966            let source = "Plain Title:\n\n    Content here";
967            let doc = DocumentLoader::from_string(source)
968                .parse()
969                .expect("parse failed");
970
971            let session = doc
972                .root
973                .children
974                .get(0)
975                .and_then(|item| {
976                    if let ContentItem::Session(session) = item {
977                        Some(session)
978                    } else {
979                        None
980                    }
981                })
982                .expect("expected session");
983
984            // No marker, so title_text() returns the full title
985            assert_eq!(session.title_text(), "Plain Title:");
986            assert_eq!(session.full_title(), "Plain Title:");
987        }
988
989        #[test]
990        fn plain_dash_not_valid_for_sessions() {
991            // Sessions should not support plain dash markers
992            // This test verifies that "- " is not treated as a marker
993            let source = "- Not A Marker:\n\n    Content here";
994            let doc = DocumentLoader::from_string(source)
995                .parse()
996                .expect("parse failed");
997
998            let session = doc
999                .root
1000                .children
1001                .get(0)
1002                .and_then(|item| {
1003                    if let ContentItem::Session(session) = item {
1004                        Some(session)
1005                    } else {
1006                        None
1007                    }
1008                })
1009                .expect("expected session");
1010
1011            // The dash should not be parsed as a marker for sessions
1012            assert!(
1013                session.marker.is_none(),
1014                "Dash should not be treated as a marker for sessions"
1015            );
1016            assert_eq!(session.full_title(), "- Not A Marker:");
1017        }
1018    }
1019}