Skip to main content

common/parser_tools/
list_grouper.rs

1use crate::entities::ListStyle;
2use crate::types::EntityId;
3
4/// Tracks active list entities across consecutive blocks so that items
5/// belonging to the same logical list share a single list entity.
6///
7/// Uses a vec indexed by indent level. When indent decreases, deeper
8/// entries are truncated so that outer lists resume correctly.
9#[derive(Default)]
10pub struct ListGrouper {
11    /// Index = indent level. Each entry: (entity_id, style, prefix, suffix).
12    /// `prefix`/`suffix` are the ordered-list delimiter affixes (e.g. `"."`,
13    /// `")"`, `"("`) so that two adjacent ordered lists with the same numbering
14    /// but a different delimiter (djot `1.` vs `1)`) are kept as separate lists.
15    /// They are always empty for Markdown/HTML, which don't model delimiters.
16    active: Vec<Option<(EntityId, ListStyle, String, String)>>,
17}
18
19impl ListGrouper {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Returns an existing list entity id if the style and indent match
25    /// a previously registered list at this level. Returns `None` if a
26    /// new list entity must be created (caller should then call `register`).
27    pub fn try_reuse(&mut self, style: &ListStyle, indent: u32) -> Option<EntityId> {
28        self.try_reuse_delim(style, indent, "", "")
29    }
30
31    /// Like [`try_reuse`](Self::try_reuse) but also requires the ordered-list
32    /// delimiter affixes to match. Used by the djot importer, which preserves
33    /// the `.`/`)`/`( )` delimiter on the `List` entity.
34    pub fn try_reuse_delim(
35        &mut self,
36        style: &ListStyle,
37        indent: u32,
38        prefix: &str,
39        suffix: &str,
40    ) -> Option<EntityId> {
41        let idx = indent as usize;
42        // Truncate deeper levels - we returned to a shallower depth
43        self.active.truncate(idx + 1);
44        if let Some(Some((id, existing_style, existing_prefix, existing_suffix))) =
45            self.active.get(idx)
46            && existing_style == style
47            && existing_prefix == prefix
48            && existing_suffix == suffix
49        {
50            return Some(*id);
51        }
52        None
53    }
54
55    /// Register a newly created list entity at the given indent level.
56    pub fn register(&mut self, id: EntityId, style: ListStyle, indent: u32) {
57        self.register_delim(id, style, indent, String::new(), String::new());
58    }
59
60    /// Like [`register`](Self::register) but records the ordered-list delimiter
61    /// affixes so subsequent [`try_reuse_delim`](Self::try_reuse_delim) calls
62    /// can distinguish lists with different delimiters.
63    pub fn register_delim(
64        &mut self,
65        id: EntityId,
66        style: ListStyle,
67        indent: u32,
68        prefix: String,
69        suffix: String,
70    ) {
71        let idx = indent as usize;
72        while self.active.len() <= idx {
73            self.active.push(None);
74        }
75        self.active[idx] = Some((id, style, prefix, suffix));
76    }
77
78    /// Clear all tracking. Call on non-list blocks, tables, or frame boundaries.
79    pub fn reset(&mut self) {
80        self.active.clear();
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn first_item_returns_none() {
90        let mut g = ListGrouper::new();
91        assert!(g.try_reuse(&ListStyle::Decimal, 0).is_none());
92    }
93
94    #[test]
95    fn consecutive_same_style_reuses() {
96        let mut g = ListGrouper::new();
97        g.register(42, ListStyle::Decimal, 0);
98        assert_eq!(g.try_reuse(&ListStyle::Decimal, 0), Some(42));
99    }
100
101    #[test]
102    fn different_style_creates_new() {
103        let mut g = ListGrouper::new();
104        g.register(42, ListStyle::Decimal, 0);
105        assert!(g.try_reuse(&ListStyle::Disc, 0).is_none());
106    }
107
108    #[test]
109    fn different_indent_creates_new() {
110        let mut g = ListGrouper::new();
111        g.register(42, ListStyle::Decimal, 0);
112        assert!(g.try_reuse(&ListStyle::Decimal, 1).is_none());
113    }
114
115    #[test]
116    fn reset_clears_all() {
117        let mut g = ListGrouper::new();
118        g.register(42, ListStyle::Decimal, 0);
119        g.reset();
120        assert!(g.try_reuse(&ListStyle::Decimal, 0).is_none());
121    }
122
123    #[test]
124    fn nested_indent_resumes_outer() {
125        let mut g = ListGrouper::new();
126        g.register(10, ListStyle::Decimal, 0);
127        g.register(20, ListStyle::LowerAlpha, 1);
128        // Return to indent 0 - should resume outer list
129        assert_eq!(g.try_reuse(&ListStyle::Decimal, 0), Some(10));
130    }
131
132    #[test]
133    fn nested_indent_different_style_creates_new() {
134        let mut g = ListGrouper::new();
135        g.register(10, ListStyle::Decimal, 0);
136        g.register(20, ListStyle::LowerAlpha, 1);
137        // Return to indent 0 with different style
138        assert!(g.try_reuse(&ListStyle::Disc, 0).is_none());
139    }
140
141    #[test]
142    fn same_style_different_delimiter_creates_new() {
143        // djot `1.` then `1)`: same numbering, different delimiter → two lists.
144        let mut g = ListGrouper::new();
145        g.register_delim(10, ListStyle::Decimal, 0, String::new(), ".".to_string());
146        assert!(g.try_reuse_delim(&ListStyle::Decimal, 0, "", ")").is_none());
147    }
148
149    #[test]
150    fn same_style_same_delimiter_reuses() {
151        let mut g = ListGrouper::new();
152        g.register_delim(10, ListStyle::Decimal, 0, "(".to_string(), ")".to_string());
153        assert_eq!(
154            g.try_reuse_delim(&ListStyle::Decimal, 0, "(", ")"),
155            Some(10)
156        );
157    }
158}