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

239 statements  

1"""because list is complex, split list parser in a new file""" 

2 

3from __future__ import annotations 

4 

5import re 

6from dataclasses import dataclass 

7from typing import TYPE_CHECKING, Any, Iterable, Optional, Match, Pattern, cast 

8from .util import strip_end 

9 

10if TYPE_CHECKING: 

11 from .block_parser import BlockParser 

12 from .core import BlockState 

13 

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) 

19 

20_LINE_HAS_TEXT = re.compile(r"(\s*)\S") 

21 

22 

23@dataclass 

24class _ListMarker: 

25 spaces: str 

26 marker: str 

27 text: str 

28 

29 @property 

30 def leading_width(self) -> int: 

31 return len(self.spaces) + len(self.marker) 

32 

33 @property 

34 def bullet(self) -> str: 

35 return self.marker[-1] 

36 

37 @property 

38 def ordered(self) -> bool: 

39 return len(self.marker) > 1 

40 

41 

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 

49 

50 

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 

61 

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 

83 

84 state.cursor = m.end() + 1 

85 item_or_none: Optional[_ListMarker] = item 

86 

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 

94 

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) 

98 

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 

105 

106 state.append_token(token) 

107 return state.cursor 

108 

109 

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) 

119 

120 

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) 

134 

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 

141 

142 child = state.child_state(_build_list_item_source(text, lines.src, continue_width)) 

143 

144 block.parse(child, rules) 

145 

146 if token["tight"] and _is_loose_list(child.tokens): 

147 token["tight"] = False 

148 

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 

157 

158 return None 

159 

160 

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 

180 

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 

187 

188 src += raw_line 

189 prev_blank_line = False 

190 state.cursor = next_pos 

191 continue 

192 

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) 

201 

202 if tok_type == "list": 

203 break 

204 

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) 

209 

210 if prev_blank_line and not has_continuation: 

211 # not a continue line, and previous line is blank 

212 break 

213 

214 src += raw_line 

215 state.cursor = next_pos 

216 

217 return _ListItemLines(src) 

218 

219 

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 ) 

226 

227 

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) 

231 

232 

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] 

241 

242 regex = "|".join(r"(?P<%s>(?<=\n)%s)" % pair for pair in pairs) 

243 return re.compile(regex, re.M) 

244 

245 

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 

258 

259 

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 

269 

270 m2 = list_item_re.match(line) 

271 if m2: 

272 return "list_item", m2 

273 

274 if m: 

275 tok_type = m.lastgroup 

276 assert tok_type is not None 

277 return tok_type, m 

278 return None 

279 

280 

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 

293 

294 

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 ) 

303 

304 

305def _compile_continue_width(text: str, leading_width: int) -> tuple[str, int]: 

306 text = _expand_leading_tabs(text, leading_width) 

307 

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 

316 

317 text = text[space_width:] + "\n" 

318 else: 

319 space_width = 1 

320 text = "" 

321 

322 continue_width = leading_width + space_width 

323 return text, continue_width 

324 

325 

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)) 

334 

335 return "\n".join(rv) 

336 

337 

338def _has_continuation_indent(line: str, columns: int) -> bool: 

339 return _count_indent(line) >= columns 

340 

341 

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 "" 

347 

348 

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:] 

366 

367 

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 

378 

379 

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