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

263 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, Match, Optional, Pattern, cast 

8 

9from .util import strip_end 

10 

11if TYPE_CHECKING: 

12 from .block_parser import BlockParser 

13 from .core import BlockState 

14 

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) 

20 

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

22 

23 

24@dataclass 

25class _ListMarker: 

26 spaces: str 

27 marker: str 

28 text: str 

29 

30 @property 

31 def leading_width(self) -> int: 

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

33 

34 @property 

35 def bullet(self) -> str: 

36 return self.marker[-1] 

37 

38 @property 

39 def ordered(self) -> bool: 

40 return len(self.marker) > 1 

41 

42 

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 

50 

51 

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 

62 

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 

84 

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

86 item_or_none: Optional[_ListMarker] = item 

87 

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 

95 

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) 

99 

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 

106 

107 state.append_token(token) 

108 return state.cursor 

109 

110 

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) 

120 

121 

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) 

135 

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 

142 

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

144 

145 block.parse(child, rules) 

146 

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

148 token["tight"] = False 

149 

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 

158 

159 return None 

160 

161 

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 

181 

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 

188 

189 src += raw_line 

190 prev_blank_line = False 

191 state.cursor = next_pos 

192 continue 

193 

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) 

202 

203 if tok_type == "list": 

204 break 

205 

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) 

210 

211 if prev_blank_line and not has_continuation: 

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

213 break 

214 

215 src += raw_line 

216 state.cursor = next_pos 

217 

218 return _ListItemLines(src) 

219 

220 

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 ) 

227 

228 

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) 

232 

233 

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] 

242 

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

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

245 

246 

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 

259 

260 

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 

270 

271 m2 = list_item_re.match(line) 

272 if m2: 

273 return "list_item", m2 

274 

275 if m: 

276 tok_type = m.lastgroup 

277 assert tok_type is not None 

278 return tok_type, m 

279 return None 

280 

281 

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 

294 

295 

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 ) 

304 

305 

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

307 text = _expand_leading_tabs(text, leading_width) 

308 

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 

317 

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

319 else: 

320 space_width = 1 

321 text = "" 

322 

323 continue_width = leading_width + space_width 

324 return text, continue_width 

325 

326 

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

335 

336 return "\n".join(rv) 

337 

338 

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 

351 

352 

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 

365 

366 if width < columns: 

367 return "" 

368 

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

375 

376 return prefix + _expand_leading_tabs(line[index:], width) 

377 

378 

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

396 

397 

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 

408 

409 

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