Skip to main content

markdown_that/plugins/cmark/block/
list.rs

1//! Ordered and bullet lists
2//!
3//! This plugin parses both kinds of lists (bullet and ordered) as well as list items.
4//!
5//! looks like `1. this` or `- this`
6//!
7//!  - <https://spec.commonmark.org/0.30/#lists>
8//!  - <https://spec.commonmark.org/0.30/#list-items>
9use crate::common::utils::find_indent_of;
10use crate::parser::block::{BlockRule, BlockState};
11use crate::plugins::cmark::block::hr::HrScanner;
12use crate::plugins::cmark::block::paragraph::Paragraph;
13use crate::{MarkdownThat, Node, NodeValue, Renderer};
14
15#[derive(Debug)]
16pub struct OrderedList {
17    pub start: u32,
18    pub marker: char,
19}
20
21impl NodeValue for OrderedList {
22    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
23        let mut attrs = node.attrs.clone();
24        let start;
25        if self.start != 1 {
26            start = self.start.to_string();
27            attrs.push(("start", start));
28        }
29        fmt.cr();
30        fmt.open("ol", &attrs);
31        fmt.cr();
32        fmt.contents(&node.children);
33        fmt.cr();
34        fmt.close("ol");
35        fmt.cr();
36    }
37}
38
39#[derive(Debug)]
40pub struct BulletList {
41    pub marker: char,
42}
43
44impl NodeValue for BulletList {
45    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
46        fmt.cr();
47        fmt.open("ul", &node.attrs);
48        fmt.cr();
49        fmt.contents(&node.children);
50        fmt.cr();
51        fmt.close("ul");
52        fmt.cr();
53    }
54}
55
56#[derive(Debug)]
57pub struct ListItem;
58
59impl NodeValue for ListItem {
60    fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
61        fmt.open("li", &node.attrs);
62        fmt.contents(&node.children);
63        fmt.close("li");
64        fmt.cr();
65    }
66}
67
68pub fn add(md: &mut MarkdownThat) {
69    md.block.add_rule::<ListScanner>().after::<HrScanner>();
70}
71
72#[doc(hidden)]
73pub struct ListScanner;
74
75impl ListScanner {
76    // Search `[-+*][\n ]`, returns next pos after marker on success
77    // or -1 on fail.
78    fn skip_bullet_list_marker(src: &str) -> Option<usize> {
79        let mut chars = src.chars();
80
81        let Some('*' | '-' | '+') = chars.next() else {
82            return None;
83        };
84
85        match chars.next() {
86            Some(' ' | '\t') | None => Some(1),
87            Some(_) => None, // " -test " - is not a list item
88        }
89    }
90
91    // Search `\d+[.)][\n ]`, returns next pos after marker on success
92    // or -1 on fail.
93    fn skip_ordered_list_marker(src: &str) -> Option<usize> {
94        let mut chars = src.chars();
95        let Some('0'..='9') = chars.next() else {
96            return None;
97        };
98
99        let mut pos = 1;
100        loop {
101            pos += 1;
102            match chars.next() {
103                Some('0'..='9') => {
104                    // List marker should have no more than 9 digits
105                    // (prevents integer overflow in browsers)
106                    if pos >= 10 {
107                        return None;
108                    }
109                }
110                Some(')' | '.') => {
111                    // found valid marker
112                    break;
113                }
114                Some(_) | None => {
115                    return None;
116                }
117            }
118        }
119
120        match chars.next() {
121            Some(' ' | '\t') | None => Some(pos),
122            Some(_) => None, // " 1.test " - is not a list item
123        }
124    }
125
126    fn mark_tight_paragraphs(nodes: &mut Vec<Node>) {
127        let mut idx = 0;
128        while idx < nodes.len() {
129            if nodes[idx].is::<Paragraph>() {
130                let children = std::mem::take(&mut nodes[idx].children);
131                let len = children.len();
132                nodes.splice(idx..idx + 1, children);
133                idx += len;
134            } else {
135                idx += 1;
136            }
137        }
138    }
139
140    fn find_marker(state: &mut BlockState, silent: bool) -> Option<(usize, Option<u32>, char)> {
141        if state.line_indent(state.line) >= state.md.max_indent {
142            return None;
143        }
144
145        // Special case:
146        //  - item 1
147        //   - item 2
148        //    - item 3
149        //     - item 4
150        //      - this one is a paragraph continuation
151        if let Some(list_indent) = state.list_indent {
152            let indent_nonspace = state.line_offsets[state.line].indent_nonspace;
153            if indent_nonspace - list_indent as i32 >= state.md.max_indent
154                && indent_nonspace < state.blk_indent as i32
155            {
156                return None;
157            }
158        }
159
160        let mut is_terminating_paragraph = false;
161
162        // limit conditions when list can interrupt
163        // a paragraph (validation mode only)
164        if silent {
165            // Next list item should still terminate previous list item;
166            //
167            // This code can fail if plugins use blkIndent as well as lists,
168            // but I hope the spec gets fixed long before that happens.
169            //
170            if state.line_indent(state.line) >= 0 {
171                is_terminating_paragraph = true;
172            }
173        }
174
175        let current_line = state.get_line(state.line);
176
177        let marker_value;
178        let pos_after_marker;
179
180        // Detect list type and position after marker
181        if let Some(p) = Self::skip_ordered_list_marker(current_line) {
182            pos_after_marker = p;
183            let int = str::parse(&current_line[..pos_after_marker - 1]).unwrap();
184            marker_value = Some(int);
185
186            // If we're starting a new ordered list right after
187            // a paragraph, it should start with 1.
188            if is_terminating_paragraph && int != 1 {
189                return None;
190            }
191        } else if let Some(p) = Self::skip_bullet_list_marker(current_line) {
192            pos_after_marker = p;
193            marker_value = None;
194        } else {
195            return None;
196        }
197
198        // If we're starting a new unordered list right after
199        // a paragraph, first line should not be empty.
200        if is_terminating_paragraph {
201            let mut chars = current_line[pos_after_marker..].chars();
202            loop {
203                match chars.next() {
204                    Some(' ' | '\t') => {}
205                    Some(_) => break,
206                    None => return None,
207                }
208            }
209        }
210
211        // We should terminate list on style change. Remember first one to compare.
212        let marker_char = current_line[..pos_after_marker]
213            .chars()
214            .next_back()
215            .unwrap();
216
217        Some((pos_after_marker, marker_value, marker_char))
218    }
219}
220
221impl BlockRule for ListScanner {
222    fn check(state: &mut BlockState) -> Option<()> {
223        if state.node.is::<BulletList>() || state.node.is::<OrderedList>() {
224            return None;
225        }
226
227        Self::find_marker(state, true).map(|_| ())
228    }
229
230    fn run(state: &mut BlockState) -> Option<(Node, usize)> {
231        let (mut pos_after_marker, marker_value, marker_char) = Self::find_marker(state, false)?;
232
233        let new_node = if let Some(int) = marker_value {
234            Node::new(OrderedList {
235                start: int,
236                marker: marker_char,
237            })
238        } else {
239            Node::new(BulletList {
240                marker: marker_char,
241            })
242        };
243
244        let old_node = std::mem::replace(&mut state.node, new_node);
245
246        //
247        // Iterate list items
248        //
249
250        let start_line = state.line;
251        let mut next_line = state.line;
252        let mut prev_empty_end = false;
253        let mut tight = true;
254        let mut current_line;
255
256        while next_line < state.line_max {
257            let offsets = &state.line_offsets[next_line];
258            let initial = offsets.indent_nonspace as usize + pos_after_marker;
259
260            let (mut indent_after_marker, first_nonspace) = find_indent_of(
261                &state.src[offsets.line_start..offsets.line_end],
262                pos_after_marker + offsets.first_nonspace - offsets.line_start,
263            );
264
265            let reached_end_of_line = first_nonspace == offsets.line_end - offsets.line_start;
266            let indent_nonspace = initial + indent_after_marker;
267
268            #[allow(clippy::if_same_then_else)]
269            if reached_end_of_line {
270                // trimming space in "-    \n  3" case, indent is 1 here
271                indent_after_marker = 1;
272            } else if indent_after_marker as i32 > state.md.max_indent {
273                // If we have more than the max indent, the indent is 1
274                // (the rest is just indented code block)
275                indent_after_marker = 1;
276            }
277
278            // "  -  test"
279            //  ^^^^^ - calculating total length of this thing
280            let indent = initial + indent_after_marker;
281
282            // Run subparser & write tokens
283            let old_node = std::mem::replace(&mut state.node, Node::new(ListItem));
284
285            // change current state, then restore it after parser subcall
286            let old_tight = state.tight;
287            let old_lineoffset = offsets.clone();
288
289            //  - example list
290            // ^ listIndent position will be here
291            //   ^ blkIndent position will be here
292            //
293            let old_list_indent = state.list_indent;
294            state.list_indent = Some(state.blk_indent as u32);
295            state.blk_indent = indent;
296
297            state.tight = true;
298            state.line_offsets[next_line].first_nonspace =
299                first_nonspace + state.line_offsets[next_line].line_start;
300            state.line_offsets[next_line].indent_nonspace = indent_nonspace as i32;
301
302            if reached_end_of_line && state.is_empty(next_line + 1) {
303                // workaround for this case
304                // (list item is empty, list terminates before "foo"):
305                // ~~~~~~~~
306                //   -
307                //
308                //     foo
309                // ~~~~~~~~
310                state.line = if state.line + 2 < state.line_max {
311                    state.line + 2
312                } else {
313                    state.line_max
314                }
315            } else {
316                state.line = next_line;
317                state.md.block.tokenize(state);
318            }
319
320            // If any of list item is tight, mark list as tight
321            if !state.tight || prev_empty_end {
322                tight = false;
323            }
324
325            // Item become loose if finish with empty line,
326            // but we should filter last element, because it means list finish
327            prev_empty_end = (state.line - next_line) > 1 && state.is_empty(state.line - 1);
328
329            state.blk_indent = state.list_indent.unwrap() as usize;
330            state.list_indent = old_list_indent;
331            state.line_offsets[next_line] = old_lineoffset;
332            state.tight = old_tight;
333
334            let end_line = state.line;
335            let mut node = std::mem::replace(&mut state.node, old_node);
336            node.srcmap = state.get_map(next_line, end_line - 1);
337            state.node.children.push(node);
338            next_line = state.line;
339
340            if next_line >= state.line_max {
341                break;
342            }
343
344            //
345            // Try to check if list is terminated or continued.
346            //
347            if state.line_indent(next_line) < 0 {
348                break;
349            }
350
351            if state.line_indent(next_line) >= state.md.max_indent {
352                break;
353            }
354
355            // fail if terminating block found
356            if state.test_rules_at_line() {
357                break;
358            }
359
360            current_line = state.get_line(state.line).to_owned();
361
362            // fail if list has another type
363            #[allow(clippy::collapsible_else_if)]
364            if marker_value.is_some() {
365                if let Some(p) = Self::skip_ordered_list_marker(&current_line) {
366                    pos_after_marker = p;
367                } else {
368                    break;
369                }
370            } else {
371                if let Some(p) = Self::skip_bullet_list_marker(&current_line) {
372                    pos_after_marker = p;
373                } else {
374                    break;
375                }
376            }
377
378            let next_marker_char = current_line[..pos_after_marker]
379                .chars()
380                .next_back()
381                .unwrap();
382            if next_marker_char != marker_char {
383                break;
384            }
385        }
386
387        // mark paragraphs tight if needed
388        if tight {
389            for child in state.node.children.iter_mut() {
390                debug_assert!(child.is::<ListItem>());
391                Self::mark_tight_paragraphs(&mut child.children);
392            }
393        }
394
395        // Finalize list
396        state.line = start_line;
397        let node = std::mem::replace(&mut state.node, old_node);
398        Some((node, next_line - state.line))
399    }
400}