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