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