Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/mistune/list_parser.py: 98%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""because list is complex, split list parser in a new file"""
3from __future__ import annotations
5import re
6from dataclasses import dataclass
7from typing import TYPE_CHECKING, Any, Iterable, Optional, Match, Pattern, cast
8from .util import strip_end
10if TYPE_CHECKING:
11 from .block_parser import BlockParser
12 from .core import BlockState
14LIST_PATTERN = (
15 r"^(?P<list_1> {0,3})"
16 r"(?P<list_2>[\*\+-]|\d{1,9}[.)])"
17 r"(?P<list_3>[ \t]*|[ \t].+)$"
18)
20_LINE_HAS_TEXT = re.compile(r"(\s*)\S")
23@dataclass
24class _ListMarker:
25 spaces: str
26 marker: str
27 text: str
29 @property
30 def leading_width(self) -> int:
31 return len(self.spaces) + len(self.marker)
33 @property
34 def bullet(self) -> str:
35 return self.marker[-1]
37 @property
38 def ordered(self) -> bool:
39 return len(self.marker) > 1
42@dataclass
43class _ListItemLines:
44 src: str
45 next_item: Optional[_ListMarker] = None
46 loose: bool = False
47 end_pos: Optional[int] = None
48 token_index: Optional[int] = None
51def parse_list(block: "BlockParser", m: Match[str], state: "BlockState") -> int:
52 """Parse tokens for ordered and unordered list."""
53 item = _create_list_marker(m, "list")
54 text = item.text
55 if not text.strip():
56 # Example 285
57 # an empty list item cannot interrupt a paragraph
58 end_pos = state.append_paragraph()
59 if end_pos:
60 return end_pos
62 marker = item.marker
63 depth = state.depth()
64 token: dict[str, Any] = {
65 "type": "list",
66 "children": [],
67 "tight": True,
68 "bullet": item.bullet,
69 "attrs": {
70 "depth": depth,
71 "ordered": item.ordered,
72 },
73 }
74 if item.ordered:
75 start = int(marker[:-1])
76 if start != 1:
77 # Example 304
78 # we allow only lists starting with 1 to interrupt paragraphs
79 end_pos = state.append_paragraph()
80 if end_pos:
81 return end_pos
82 token["attrs"]["start"] = start
84 state.cursor = m.end() + 1
85 item_or_none: Optional[_ListMarker] = item
87 if depth >= block.max_nested_level - 1:
88 # At the nesting limit, stop descending into any further container
89 # blocks. Trimming only "list" still allowed lists and block quotes to
90 # recurse into each other without bound (RecursionError).
91 rules = [rule for rule in block.list_rules if rule not in ("list", "block_quote")]
92 else:
93 rules = block.list_rules
95 bullet = _get_list_bullet(item.bullet)
96 while item_or_none:
97 item_or_none = _parse_list_item(block, bullet, item_or_none, token, state, rules)
99 end_pos = cast(Optional[int], token.pop("_end_pos", None))
100 _transform_tight_list(token)
101 if end_pos:
102 index = cast(int, token.pop("_tok_index"))
103 state.tokens.insert(index, token)
104 return end_pos
106 state.append_token(token)
107 return state.cursor
110def _transform_tight_list(token: dict[str, Any]) -> None:
111 if token["tight"]:
112 # reset tight list item
113 for list_item in token["children"]:
114 for tok in list_item["children"]:
115 if tok["type"] == "paragraph":
116 tok["type"] = "block_text"
117 elif tok["type"] == "list":
118 _transform_tight_list(tok)
121def _parse_list_item(
122 block: "BlockParser",
123 bullet: str,
124 item: _ListMarker,
125 token: dict[str, Any],
126 state: "BlockState",
127 rules: list[str],
128) -> _ListMarker | None:
129 text = item.text
130 leading_width = item.leading_width
131 text, continue_width = _compile_continue_width(text, leading_width)
132 list_item_re = re.compile(_compile_list_item_pattern(bullet, leading_width))
133 break_sc = _compile_list_break_sc(block, leading_width)
135 lines = _collect_list_item_lines(block, list_item_re, break_sc, state, text, continue_width)
136 if lines.loose:
137 token["tight"] = False
138 if lines.end_pos is not None:
139 token["_tok_index"] = lines.token_index
140 token["_end_pos"] = lines.end_pos
142 child = state.child_state(_build_list_item_source(text, lines.src, continue_width))
144 block.parse(child, rules)
146 if token["tight"] and _is_loose_list(child.tokens):
147 token["tight"] = False
149 token["children"].append(
150 {
151 "type": "list_item",
152 "children": child.tokens,
153 }
154 )
155 if lines.next_item:
156 return lines.next_item
158 return None
161def _collect_list_item_lines(
162 block: "BlockParser",
163 list_item_re: Pattern[str],
164 break_sc: Pattern[str],
165 state: "BlockState",
166 text: str,
167 continue_width: int,
168) -> _ListItemLines:
169 src = ""
170 next_item = None
171 prev_blank_line = False
172 while state.cursor < state.cursor_max:
173 raw_line = state.get_line(state.cursor)
174 next_pos = state.cursor + len(raw_line)
175 if block.BLANK_LINE.match(raw_line):
176 src += "\n"
177 prev_blank_line = True
178 state.cursor = next_pos
179 continue
181 has_continuation = _has_continuation_indent(raw_line, continue_width)
182 if has_continuation:
183 if prev_blank_line and not text and not src.strip():
184 # Example 280
185 # A list item can begin with at most one blank line
186 break
188 src += raw_line
189 prev_blank_line = False
190 state.cursor = next_pos
191 continue
193 line = _expand_leading_tabs(raw_line)
194 line_break = _match_list_item_break(list_item_re, break_sc, state, line)
195 if line_break:
196 tok_type, m = line_break
197 if tok_type == "list_item":
198 next_item = _create_list_marker(m, "listitem")
199 state.cursor = next_pos
200 return _ListItemLines(src, next_item=next_item, loose=prev_blank_line)
202 if tok_type == "list":
203 break
205 tok_index = len(state.tokens)
206 end_pos = block.parse_method(m, state)
207 if end_pos:
208 return _ListItemLines(src, end_pos=end_pos, token_index=tok_index)
210 if prev_blank_line and not has_continuation:
211 # not a continue line, and previous line is blank
212 break
214 src += raw_line
215 state.cursor = next_pos
217 return _ListItemLines(src)
220def _create_list_marker(m: Match[str], prefix: str) -> _ListMarker:
221 return _ListMarker(
222 spaces=m.group(prefix + "_1"),
223 marker=m.group(prefix + "_2"),
224 text=m.group(prefix + "_3"),
225 )
228def _build_list_item_source(text: str, src: str, continue_width: int) -> str:
229 text += _clean_list_item_text(src, continue_width)
230 return strip_end(text)
233def _compile_list_break_sc(block: "BlockParser", leading_width: int) -> Pattern[str]:
234 pairs = [(name, block.specification[name]) for name in _get_list_break_rules(block)]
235 if leading_width < 3:
236 # Relax the leading indent bound only. Matching on a bare "3" would
237 # rewrite the first quantifier of any rule that has no indent prefix --
238 # e.g. a fenced directive's "{3,}" marker run.
239 _repl = " {0,%d}" % leading_width
240 pairs = [(n, p.replace(" {0,3}", _repl, 1)) for n, p in pairs]
242 regex = "|".join(r"(?P<%s>(?<=\n)%s)" % pair for pair in pairs)
243 return re.compile(regex, re.M)
246def _get_list_break_rules(block: "BlockParser") -> list[str]:
247 rules = [
248 "thematic_break",
249 "fenced_code",
250 "atx_heading",
251 "block_quote",
252 "block_html",
253 "list",
254 ]
255 if "fenced_directive" in block.specification:
256 rules.insert(1, "fenced_directive")
257 return rules
260def _match_list_item_break(
261 list_item_re: Pattern[str],
262 break_sc: Pattern[str],
263 state: "BlockState",
264 line: str,
265) -> tuple[str, Match[str]] | None:
266 m = break_sc.match(state.src, state.cursor)
267 if m and m.lastgroup == "thematic_break":
268 return "thematic_break", m
270 m2 = list_item_re.match(line)
271 if m2:
272 return "list_item", m2
274 if m:
275 tok_type = m.lastgroup
276 assert tok_type is not None
277 return tok_type, m
278 return None
281def _get_list_bullet(c: str) -> str:
282 if c == ".":
283 bullet = r"\d{0,9}\."
284 elif c == ")":
285 bullet = r"\d{0,9}\)"
286 elif c == "*":
287 bullet = r"\*"
288 elif c == "+":
289 bullet = r"\+"
290 else:
291 bullet = "-"
292 return bullet
295def _compile_list_item_pattern(bullet: str, leading_width: int) -> str:
296 if leading_width > 3:
297 leading_width = 3
298 return (
299 r"^(?P<listitem_1> {0," + str(leading_width) + "})"
300 r"(?P<listitem_2>" + bullet + ")"
301 r"(?P<listitem_3>[ \t]*|[ \t][^\n]+)$"
302 )
305def _compile_continue_width(text: str, leading_width: int) -> tuple[str, int]:
306 text = _expand_leading_tabs(text, leading_width)
308 m2 = _LINE_HAS_TEXT.match(text)
309 if m2:
310 # indent code, startswith 5 spaces
311 indent = _count_indent(text)
312 if indent >= 5:
313 space_width = 1
314 else:
315 space_width = indent
317 text = text[space_width:] + "\n"
318 else:
319 space_width = 1
320 text = ""
322 continue_width = leading_width + space_width
323 return text, continue_width
326def _clean_list_item_text(src: str, continue_width: int) -> str:
327 rv = []
328 lines = src.split("\n")
329 for line in lines:
330 if _has_continuation_indent(line, continue_width):
331 rv.append(_strip_continuation_indent(line, continue_width))
332 else:
333 rv.append(_expand_leading_tabs(line))
335 return "\n".join(rv)
338def _has_continuation_indent(line: str, columns: int) -> bool:
339 return _count_indent(line) >= columns
342def _strip_continuation_indent(line: str, columns: int) -> str:
343 expanded = _expand_leading_tabs(line)
344 if len(expanded) >= columns:
345 return expanded[columns:]
346 return ""
349def _expand_leading_tabs(line: str, start_column: int = 0) -> str:
350 column = start_column
351 parts = []
352 index = 0
353 while index < len(line):
354 c = line[index]
355 if c == " ":
356 parts.append(" ")
357 column += 1
358 elif c == "\t":
359 size = 4 - column % 4
360 parts.append(" " * size)
361 column += size
362 else:
363 break
364 index += 1
365 return "".join(parts) + line[index:]
368def _count_indent(text: str) -> int:
369 column = 0
370 for c in text:
371 if c == " ":
372 column += 1
373 elif c == "\t":
374 column += 4 - column % 4
375 else:
376 break
377 return column
380def _is_loose_list(tokens: Iterable[dict[str, Any]]) -> bool:
381 paragraph_count = 0
382 for tok in tokens:
383 if tok["type"] == "blank_line":
384 return True
385 if tok["type"] == "paragraph":
386 paragraph_count += 1
387 if paragraph_count > 1:
388 return True
389 return False