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, Match, Optional, Pattern, cast
9from .util import strip_end
11if TYPE_CHECKING:
12 from .block_parser import BlockParser
13 from .core import BlockState
15LIST_PATTERN = (
16 r"^(?P<list_1> {0,3})"
17 r"(?P<list_2>[\*\+-]|\d{1,9}[.)])"
18 r"(?P<list_3>[ \t]*|[ \t].+)$"
19)
21_LINE_HAS_TEXT = re.compile(r"(\s*)\S")
24@dataclass
25class _ListMarker:
26 spaces: str
27 marker: str
28 text: str
30 @property
31 def leading_width(self) -> int:
32 return len(self.spaces) + len(self.marker)
34 @property
35 def bullet(self) -> str:
36 return self.marker[-1]
38 @property
39 def ordered(self) -> bool:
40 return len(self.marker) > 1
43@dataclass
44class _ListItemLines:
45 src: str
46 next_item: Optional[_ListMarker] = None
47 loose: bool = False
48 end_pos: Optional[int] = None
49 token_index: Optional[int] = None
52def parse_list(block: "BlockParser", m: Match[str], state: "BlockState") -> int:
53 """Parse tokens for ordered and unordered list."""
54 item = _create_list_marker(m, "list")
55 text = item.text
56 if not text.strip():
57 # Example 285
58 # an empty list item cannot interrupt a paragraph
59 end_pos = state.append_paragraph()
60 if end_pos:
61 return end_pos
63 marker = item.marker
64 depth = state.depth()
65 token: dict[str, Any] = {
66 "type": "list",
67 "children": [],
68 "tight": True,
69 "bullet": item.bullet,
70 "attrs": {
71 "depth": depth,
72 "ordered": item.ordered,
73 },
74 }
75 if item.ordered:
76 start = int(marker[:-1])
77 if start != 1:
78 # Example 304
79 # we allow only lists starting with 1 to interrupt paragraphs
80 end_pos = state.append_paragraph()
81 if end_pos:
82 return end_pos
83 token["attrs"]["start"] = start
85 state.cursor = m.end() + 1
86 item_or_none: Optional[_ListMarker] = item
88 if depth >= block.max_nested_level - 1:
89 # At the nesting limit, stop descending into any further container
90 # blocks. Trimming only "list" still allowed lists and block quotes to
91 # recurse into each other without bound (RecursionError).
92 rules = [rule for rule in block.list_rules if rule not in ("list", "block_quote")]
93 else:
94 rules = block.list_rules
96 bullet = _get_list_bullet(item.bullet)
97 while item_or_none:
98 item_or_none = _parse_list_item(block, bullet, item_or_none, token, state, rules)
100 end_pos = cast(Optional[int], token.pop("_end_pos", None))
101 _transform_tight_list(token)
102 if end_pos:
103 index = cast(int, token.pop("_tok_index"))
104 state.tokens.insert(index, token)
105 return end_pos
107 state.append_token(token)
108 return state.cursor
111def _transform_tight_list(token: dict[str, Any]) -> None:
112 if token["tight"]:
113 # reset tight list item
114 for list_item in token["children"]:
115 for tok in list_item["children"]:
116 if tok["type"] == "paragraph":
117 tok["type"] = "block_text"
118 elif tok["type"] == "list":
119 _transform_tight_list(tok)
122def _parse_list_item(
123 block: "BlockParser",
124 bullet: str,
125 item: _ListMarker,
126 token: dict[str, Any],
127 state: "BlockState",
128 rules: list[str],
129) -> _ListMarker | None:
130 text = item.text
131 leading_width = item.leading_width
132 text, continue_width = _compile_continue_width(text, leading_width)
133 list_item_re = re.compile(_compile_list_item_pattern(bullet, leading_width))
134 break_sc = _compile_list_break_sc(block, leading_width)
136 lines = _collect_list_item_lines(block, list_item_re, break_sc, state, text, continue_width)
137 if lines.loose:
138 token["tight"] = False
139 if lines.end_pos is not None:
140 token["_tok_index"] = lines.token_index
141 token["_end_pos"] = lines.end_pos
143 child = state.child_state(_build_list_item_source(text, lines.src, continue_width))
145 block.parse(child, rules)
147 if token["tight"] and _is_loose_list(child.tokens):
148 token["tight"] = False
150 token["children"].append(
151 {
152 "type": "list_item",
153 "children": child.tokens,
154 }
155 )
156 if lines.next_item:
157 return lines.next_item
159 return None
162def _collect_list_item_lines(
163 block: "BlockParser",
164 list_item_re: Pattern[str],
165 break_sc: Pattern[str],
166 state: "BlockState",
167 text: str,
168 continue_width: int,
169) -> _ListItemLines:
170 src = ""
171 next_item = None
172 prev_blank_line = False
173 while state.cursor < state.cursor_max:
174 raw_line = state.get_line(state.cursor)
175 next_pos = state.cursor + len(raw_line)
176 if block.BLANK_LINE.match(raw_line):
177 src += "\n"
178 prev_blank_line = True
179 state.cursor = next_pos
180 continue
182 has_continuation = _has_continuation_indent(raw_line, continue_width)
183 if has_continuation:
184 if prev_blank_line and not text and not src.strip():
185 # Example 280
186 # A list item can begin with at most one blank line
187 break
189 src += raw_line
190 prev_blank_line = False
191 state.cursor = next_pos
192 continue
194 line = _expand_leading_tabs(raw_line)
195 line_break = _match_list_item_break(list_item_re, break_sc, state, line)
196 if line_break:
197 tok_type, m = line_break
198 if tok_type == "list_item":
199 next_item = _create_list_marker(m, "listitem")
200 state.cursor = next_pos
201 return _ListItemLines(src, next_item=next_item, loose=prev_blank_line)
203 if tok_type == "list":
204 break
206 tok_index = len(state.tokens)
207 end_pos = block.parse_method(m, state)
208 if end_pos:
209 return _ListItemLines(src, end_pos=end_pos, token_index=tok_index)
211 if prev_blank_line and not has_continuation:
212 # not a continue line, and previous line is blank
213 break
215 src += raw_line
216 state.cursor = next_pos
218 return _ListItemLines(src)
221def _create_list_marker(m: Match[str], prefix: str) -> _ListMarker:
222 return _ListMarker(
223 spaces=m.group(prefix + "_1"),
224 marker=m.group(prefix + "_2"),
225 text=m.group(prefix + "_3"),
226 )
229def _build_list_item_source(text: str, src: str, continue_width: int) -> str:
230 text += _clean_list_item_text(src, continue_width)
231 return strip_end(text)
234def _compile_list_break_sc(block: "BlockParser", leading_width: int) -> Pattern[str]:
235 pairs = [(name, block.specification[name]) for name in _get_list_break_rules(block)]
236 if leading_width < 3:
237 # Relax the leading indent bound only. Matching on a bare "3" would
238 # rewrite the first quantifier of any rule that has no indent prefix --
239 # e.g. a fenced directive's "{3,}" marker run.
240 _repl = " {0,%d}" % leading_width
241 pairs = [(n, p.replace(" {0,3}", _repl, 1)) for n, p in pairs]
243 regex = "|".join(r"(?P<%s>(?<=\n)%s)" % pair for pair in pairs)
244 return re.compile(regex, re.M)
247def _get_list_break_rules(block: "BlockParser") -> list[str]:
248 rules = [
249 "thematic_break",
250 "fenced_code",
251 "atx_heading",
252 "block_quote",
253 "block_html",
254 "list",
255 ]
256 if "fenced_directive" in block.specification:
257 rules.insert(1, "fenced_directive")
258 return rules
261def _match_list_item_break(
262 list_item_re: Pattern[str],
263 break_sc: Pattern[str],
264 state: "BlockState",
265 line: str,
266) -> tuple[str, Match[str]] | None:
267 m = break_sc.match(state.src, state.cursor)
268 if m and m.lastgroup == "thematic_break":
269 return "thematic_break", m
271 m2 = list_item_re.match(line)
272 if m2:
273 return "list_item", m2
275 if m:
276 tok_type = m.lastgroup
277 assert tok_type is not None
278 return tok_type, m
279 return None
282def _get_list_bullet(c: str) -> str:
283 if c == ".":
284 bullet = r"\d{1,9}\."
285 elif c == ")":
286 bullet = r"\d{1,9}\)"
287 elif c == "*":
288 bullet = r"\*"
289 elif c == "+":
290 bullet = r"\+"
291 else:
292 bullet = "-"
293 return bullet
296def _compile_list_item_pattern(bullet: str, leading_width: int) -> str:
297 if leading_width > 3:
298 leading_width = 3
299 return (
300 r"^(?P<listitem_1> {0," + str(leading_width) + "})"
301 r"(?P<listitem_2>" + bullet + ")"
302 r"(?P<listitem_3>[ \t]*|[ \t][^\n]+)$"
303 )
306def _compile_continue_width(text: str, leading_width: int) -> tuple[str, int]:
307 text = _expand_leading_tabs(text, leading_width)
309 m2 = _LINE_HAS_TEXT.match(text)
310 if m2:
311 # indent code, startswith 5 spaces
312 indent = _count_indent(text)
313 if indent >= 5:
314 space_width = 1
315 else:
316 space_width = indent
318 text = text[space_width:] + "\n"
319 else:
320 space_width = 1
321 text = ""
323 continue_width = leading_width + space_width
324 return text, continue_width
327def _clean_list_item_text(src: str, continue_width: int) -> str:
328 rv = []
329 lines = src.split("\n")
330 for line in lines:
331 if _has_continuation_indent(line, continue_width):
332 rv.append(_strip_continuation_indent(line, continue_width))
333 else:
334 rv.append(_expand_leading_tabs(line))
336 return "\n".join(rv)
339def _has_continuation_indent(line: str, columns: int) -> bool:
340 width = 0
341 for char in line:
342 if char == " ":
343 width += 1
344 elif char == "\t":
345 width += 4 - width % 4
346 else:
347 break
348 if width >= columns:
349 return True
350 return False
353def _strip_continuation_indent(line: str, columns: int) -> str:
354 width = 0
355 index = 0
356 while index < len(line) and width < columns:
357 char = line[index]
358 if char == " ":
359 width += 1
360 elif char == "\t":
361 width += 4 - width % 4
362 else:
363 break
364 index += 1
366 if width < columns:
367 return ""
369 prefix = " " * (width - columns)
370 if index < len(line) and line[index] == " ":
371 if "\t" not in line[index:]:
372 return prefix + line[index:]
373 elif index == len(line) or line[index] != "\t":
374 return prefix + line[index:]
376 return prefix + _expand_leading_tabs(line[index:], width)
379def _expand_leading_tabs(line: str, start_column: int = 0) -> str:
380 column = start_column
381 parts = []
382 index = 0
383 while index < len(line):
384 c = line[index]
385 if c == " ":
386 parts.append(" ")
387 column += 1
388 elif c == "\t":
389 size = 4 - column % 4
390 parts.append(" " * size)
391 column += size
392 else:
393 break
394 index += 1
395 return "".join(parts) + line[index:]
398def _count_indent(text: str) -> int:
399 column = 0
400 for c in text:
401 if c == " ":
402 column += 1
403 elif c == "\t":
404 column += 4 - column % 4
405 else:
406 break
407 return column
410def _is_loose_list(tokens: Iterable[dict[str, Any]]) -> bool:
411 paragraph_count = 0
412 for tok in tokens:
413 if tok["type"] == "blank_line":
414 return True
415 if tok["type"] == "paragraph":
416 paragraph_count += 1
417 if paragraph_count > 1:
418 return True
419 return False