wayver's git archive


an obsidian renderer
git clone https://git.wayver.dev/sable

sable-markdown/src/parser/blocks/list.rs@main

raw
Date Commit Message Author Files + -
2026-02-23 01:55 initial mvp wayverd 139 17808 0
...

1use nom::{
2    IResult, Parser,
3    branch::alt,
4    character::complete::{char, one_of, space0},
5    combinator::{map, not, opt, peek, recognize, value, verify},
6    multi::{many_m_n, many0, many1},
7    sequence::{delimited, preceded, terminated},
8};
9
10use crate::{
11    ast::{ListBulletKind, ListItem, ListKind, ListOrderedKindOptions, TaskState},
12    parser::util::{line_terminated, not_eof_or_eol0, not_eof_or_eol1},
13};
14
15fn list_item_task_state(input: &str) -> IResult<&str, TaskState> {
16    delimited(
17        char('['),
18        alt((
19            value(TaskState::Complete, one_of("xX")),
20            value(TaskState::Incomplete, char(' ')),
21        )),
22        char(']'),
23    )
24    .parse(input)
25}
26
27fn list_marker(input: &str) -> IResult<&str, ListKind> {
28    alt((
29        list_marker_ordered,
30        list_marker_star,
31        list_marker_plus,
32        list_marker_dash,
33    ))
34    .parse(input)
35}
36
37fn list_marker_star(input: &str) -> IResult<&str, ListKind> {
38    map(char('*'), |_| ListKind::Bullet(ListBulletKind::Star)).parse(input)
39}
40
41fn list_marker_plus(input: &str) -> IResult<&str, ListKind> {
42    map(char('+'), |_| ListKind::Bullet(ListBulletKind::Plus)).parse(input)
43}
44
45fn list_marker_dash(input: &str) -> IResult<&str, ListKind> {
46    map(char('-'), |_| ListKind::Bullet(ListBulletKind::Dash)).parse(input)
47}
48
49fn list_marker_ordered(input: &str) -> IResult<&str, ListKind> {
50    map(
51        terminated(nom::character::complete::u64, one_of(".)")),
52        |start| ListKind::Ordered(ListOrderedKindOptions { start }),
53    )
54    .parse(input)
55}
56
57fn list_marker_followed_by_spaces(
58    input: &str,
59) -> IResult<&str, (ListKind, usize, Option<TaskState>)> {
60    let (remaining, kind) = delimited(
61        many_m_n(0, 3, char(' ')),
62        list_marker,
63        many_m_n(1, 4, char(' ')),
64    )
65    .parse(input)?;
66
67    let consumed = input.len() - remaining.len();
68
69    let (input, task_state) = opt(terminated(list_item_task_state, char(' '))).parse(remaining)?;
70
71    Ok((input, (kind, consumed, task_state)))
72}
73
74fn list_marker_followed_by_newline(
75    input: &str,
76) -> IResult<&str, (ListKind, usize, Option<TaskState>)> {
77    let (remaining, kind) = preceded(many_m_n(0, 3, char(' ')), list_marker).parse(input)?;
78
79    // Cases:
80    // 1.
81    // 1.____
82    if let Ok((tail, _)) = line_terminated(space0).parse(remaining) {
83        // Calculate prefix length: consumed + 1 space
84        let consumed = input.len() - remaining.len() + 1;
85
86        return Ok((tail, (kind, consumed, None)));
87    }
88
89    let (remaining, _) = many_m_n(0, 3, char(' ')).parse(remaining)?;
90    let consumed = input.len() - remaining.len() + 1;
91
92    let (remaining, task_state) = line_terminated(list_item_task_state).parse(remaining)?;
93
94    Ok((remaining, (kind, consumed, Some(task_state))))
95}
96
97pub(super) fn list_marker_with_span_size(
98    input: &str,
99) -> IResult<&str, (ListKind, usize, Option<TaskState>, String)> {
100    alt((
101        map(
102            list_marker_followed_by_newline,
103            |(list_kind, prefix_length, task_state)| {
104                (list_kind, prefix_length, task_state, String::new())
105            },
106        ),
107        (map(
108            (
109                list_marker_followed_by_spaces,
110                line_terminated(not_eof_or_eol0),
111            ),
112            |((list_kind, prefix_length, task_state), s)| {
113                (list_kind, prefix_length, task_state, s.to_string())
114            },
115        )),
116    ))
117    .parse(input)
118}
119
120fn list_item_rest_line(
121    list_kind: ListKind,
122    prefix_length: usize,
123) -> impl FnMut(&str) -> IResult<&str, Vec<&str>> {
124    move |input: &str| {
125        // Stop parsing lines on EOF
126        if input.is_empty() {
127            return Err(nom::Err::Error(nom::error::Error::new(
128                input,
129                nom::error::ErrorKind::Eof,
130            )));
131        }
132
133        let marker_parser = match list_kind {
134            ListKind::Ordered(_) => list_marker_ordered,
135            ListKind::Bullet(ListBulletKind::Star) => list_marker_star,
136            ListKind::Bullet(ListBulletKind::Plus) => list_marker_plus,
137            ListKind::Bullet(ListBulletKind::Dash) => list_marker_dash,
138        };
139
140        line_terminated(preceded(
141            peek(not(alt((
142                value((), crate::parser::blocks::thematic_break::thematic_break()),
143                value(
144                    (),
145                    (
146                        verify(
147                            recognize(many_m_n(0, prefix_length, char(' '))),
148                            |indent: &str| indent.len() < prefix_length,
149                        ),
150                        marker_parser,
151                    ),
152                ),
153            )))),
154            alt((
155                // If starts with 0 <= prefix_length spaces
156                preceded(
157                    many_m_n(0, prefix_length, char(' ')),
158                    map(not_eof_or_eol1, |v| vec![v]),
159                ),
160                // If this is empty line, followed by prefix_length spaces
161                map(
162                    (
163                        recognize(many1(line_terminated(space0))),
164                        preceded(
165                            many_m_n(prefix_length, prefix_length, char(' ')),
166                            not_eof_or_eol1,
167                        ),
168                    ),
169                    |(newlines, content)| vec![newlines, content],
170                ),
171            )),
172        ))
173        .parse(input)
174    }
175}
176
177fn list_item_lines(
178    list_kind: ListKind,
179    prefix_length: usize,
180) -> impl FnMut(&str) -> IResult<&str, Vec<Vec<&str>>> {
181    move |input: &str| many0(list_item_rest_line(list_kind.clone(), prefix_length)).parse(input)
182}
183
184pub(super) fn list_item() -> impl FnMut(&str) -> IResult<&str, (ListKind, ListItem)> {
185    move |input: &str| {
186        let (input, (list_kind, item_prefix_length, task_state, first_line)) =
187            list_marker_with_span_size(input)?;
188
189        let (input, rest_lines) =
190            list_item_lines(list_kind.clone(), item_prefix_length).parse(input)?;
191
192        let total_size = first_line.len() + rest_lines.len();
193        let mut item_content = String::with_capacity(total_size);
194        if !first_line.is_empty() {
195            item_content.push_str(&first_line);
196        }
197        for line in rest_lines {
198            item_content.push('\n');
199            for subline in line {
200                item_content.push_str(subline);
201            }
202        }
203
204        let (_, blocks) = many0(crate::parser::blocks::block())
205            .parse(&item_content)
206            .map_err(|err| err.map_input(|_| input))?;
207
208        let blocks = blocks.into_iter().flatten().collect();
209
210        let item = ListItem {
211            task: task_state,
212            blocks,
213        };
214        Ok((input, (list_kind, item)))
215    }
216}
217
218pub(super) fn list() -> impl FnMut(&str) -> IResult<&str, crate::ast::List> {
219    move |input: &str| {
220        let (input, items) = many1(list_item()).parse(input)?;
221
222        // With many1(), first element always present
223        let first_item = items.first().unwrap();
224
225        let list = crate::ast::List {
226            kind: first_item.0.clone(),
227            items: items.into_iter().map(|(_, item)| item).collect(),
228        };
229
230        Ok((input, list))
231    }
232}
233