Skip to main content

panache_parser/syntax/
lists.rs

1//! List AST node wrappers.
2//!
3//! Lists in Markdown/Pandoc can be either:
4//! - **Compact (tight)**: List items contain PLAIN nodes (no blank lines between items)
5//! - **Loose**: List items contain PARAGRAPH nodes (blank lines between items)
6
7use super::ast::{AstChildren, support};
8use super::{AstNode, PanacheLanguage, SyntaxKind, SyntaxNode};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ListKind {
12    Bullet,
13    Ordered,
14    Task,
15}
16
17pub struct List(SyntaxNode);
18
19impl AstNode for List {
20    type Language = PanacheLanguage;
21
22    fn can_cast(kind: SyntaxKind) -> bool {
23        kind == SyntaxKind::LIST
24    }
25
26    fn cast(syntax: SyntaxNode) -> Option<Self> {
27        if Self::can_cast(syntax.kind()) {
28            Some(Self(syntax))
29        } else {
30            None
31        }
32    }
33
34    fn syntax(&self) -> &SyntaxNode {
35        &self.0
36    }
37}
38
39impl List {
40    /// Returns true if this is a loose list (has blank lines between items).
41    pub fn is_loose(&self) -> bool {
42        self.0
43            .children()
44            .any(|n| n.kind() == SyntaxKind::BLANK_LINE)
45    }
46
47    /// Returns true if this is a compact/tight list (no blank lines between items).
48    ///
49    /// This is the inverse of `is_loose()`.
50    pub fn is_compact(&self) -> bool {
51        !self.is_loose()
52    }
53
54    /// Returns an iterator over the list items (LIST_ITEM nodes).
55    pub fn items(&self) -> AstChildren<ListItem> {
56        support::children(&self.0)
57    }
58
59    /// Returns the semantic kind of this list.
60    pub fn kind(&self) -> Option<ListKind> {
61        let first_item = self.items().next()?;
62        if first_item.is_task() {
63            return Some(ListKind::Task);
64        }
65        let marker = first_item.marker()?;
66        if matches!(marker.as_str(), "-" | "*" | "+") {
67            Some(ListKind::Bullet)
68        } else {
69            Some(ListKind::Ordered)
70        }
71    }
72}
73
74pub struct ListItem(SyntaxNode);
75
76impl AstNode for ListItem {
77    type Language = PanacheLanguage;
78
79    fn can_cast(kind: SyntaxKind) -> bool {
80        kind == SyntaxKind::LIST_ITEM
81    }
82
83    fn cast(syntax: SyntaxNode) -> Option<Self> {
84        if Self::can_cast(syntax.kind()) {
85            Some(Self(syntax))
86        } else {
87            None
88        }
89    }
90
91    fn syntax(&self) -> &SyntaxNode {
92        &self.0
93    }
94}
95
96impl ListItem {
97    /// Returns true if this list item contains PARAGRAPH nodes (loose style).
98    pub fn is_loose(&self) -> bool {
99        self.0
100            .children()
101            .any(|child| child.kind() == SyntaxKind::PARAGRAPH)
102    }
103
104    /// Returns true if this list item contains PLAIN nodes (compact style).
105    pub fn is_compact(&self) -> bool {
106        self.0
107            .children()
108            .any(|child| child.kind() == SyntaxKind::PLAIN)
109    }
110
111    pub fn marker(&self) -> Option<String> {
112        self.0.children_with_tokens().find_map(|elem| {
113            elem.as_token()
114                .filter(|token| token.kind() == SyntaxKind::LIST_MARKER)
115                .map(|token| token.text().to_string())
116        })
117    }
118
119    pub fn is_task(&self) -> bool {
120        self.0.children_with_tokens().any(|elem| {
121            elem.as_token()
122                .is_some_and(|token| token.kind() == SyntaxKind::TASK_CHECKBOX)
123        })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::parse;
131
132    #[test]
133    fn list_wrapper_compact() {
134        let input = "- First\n- Second\n- Third\n";
135        let tree = parse(input, None);
136
137        let list_node = tree
138            .descendants()
139            .find(|n| n.kind() == SyntaxKind::LIST)
140            .expect("Should find LIST node");
141
142        let list = List::cast(list_node).expect("Should cast to List");
143
144        assert!(list.is_compact(), "List should be compact");
145        assert!(!list.is_loose(), "List should not be loose");
146        assert_eq!(list.items().count(), 3, "Should have 3 items");
147    }
148
149    #[test]
150    fn list_wrapper_loose() {
151        let input = "- First\n\n- Second\n\n- Third\n";
152        let tree = parse(input, None);
153
154        let list_node = tree
155            .descendants()
156            .find(|n| n.kind() == SyntaxKind::LIST)
157            .expect("Should find LIST node");
158
159        let list = List::cast(list_node).expect("Should cast to List");
160
161        assert!(list.is_loose(), "List should be loose");
162        assert!(!list.is_compact(), "List should not be compact");
163        assert_eq!(list.items().count(), 3, "Should have 3 items");
164    }
165
166    #[test]
167    fn list_item_wrapper() {
168        let input = "- First item\n- Second item\n";
169        let tree = parse(input, None);
170
171        let list = tree
172            .descendants()
173            .find_map(List::cast)
174            .expect("Should find List");
175
176        assert_eq!(list.items().count(), 2, "Should have 2 list items");
177
178        let first_item = list.items().next().expect("Should have list item");
179        assert!(
180            first_item.is_compact(),
181            "First item should be compact (PLAIN)"
182        );
183        assert!(!first_item.is_loose(), "First item should not be loose");
184    }
185
186    #[test]
187    fn list_items_iterator() {
188        let input = "1. First\n2. Second\n3. Third\n4. Fourth\n";
189        let tree = parse(input, None);
190
191        let list_node = tree
192            .descendants()
193            .find(|n| n.kind() == SyntaxKind::LIST)
194            .expect("Should find LIST node");
195
196        let list = List::cast(list_node).expect("Should cast to List");
197
198        assert_eq!(list.items().count(), 4, "Should have 4 items");
199        for item in list.items() {
200            assert_eq!(
201                item.syntax().kind(),
202                SyntaxKind::LIST_ITEM,
203                "Each item should be LIST_ITEM"
204            );
205        }
206    }
207
208    #[test]
209    fn list_kind_detection() {
210        let bullet_tree = parse("- First\n- Second\n", None);
211        let bullet_list = bullet_tree
212            .descendants()
213            .find_map(List::cast)
214            .expect("Should find bullet list");
215        assert_eq!(bullet_list.kind(), Some(ListKind::Bullet));
216
217        let ordered_tree = parse("1. First\n2. Second\n", None);
218        let ordered_list = ordered_tree
219            .descendants()
220            .find_map(List::cast)
221            .expect("Should find ordered list");
222        assert_eq!(ordered_list.kind(), Some(ListKind::Ordered));
223
224        let task_tree = parse("- [ ] First\n- [x] Second\n", None);
225        let task_list = task_tree
226            .descendants()
227            .find_map(List::cast)
228            .expect("Should find task list");
229        assert_eq!(task_list.kind(), Some(ListKind::Task));
230    }
231}