Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/mistune/_inline/emphasis.py: 97%

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

279 statements  

1from __future__ import annotations 

2 

3import re 

4from dataclasses import dataclass 

5from typing import Any, Dict, List, Tuple, cast 

6 

7_CHARREF_PREFIX = re.compile(r"(#[0-9]{1,7};|#[xX][0-9a-fA-F]+;|[^\t\n\f <&#;]{1,32};)") 

8 

9 

10def is_entity_boundary(left: str, right: str) -> bool: 

11 return left.endswith("&") and _CHARREF_PREFIX.match(right) is not None 

12 

13 

14@dataclass 

15class _Delimiter: 

16 index: int 

17 marker: str 

18 length: int 

19 can_open: bool 

20 can_close: bool 

21 #: original run length, used by the CommonMark multiple-of-3 rule even 

22 #: after the run has been partially consumed 

23 orig_length: int = 0 

24 order: int = 0 

25 

26 

27class _DelimiterIndex: 

28 """Track part positions without rewriting every delimiter after a splice.""" 

29 

30 def __init__(self, delimiters: List[_Delimiter], part_count: int) -> None: 

31 self._tree = [0] * (part_count + 1) 

32 self._next = list(range(len(delimiters) + 1)) 

33 

34 def current(self, delimiter: _Delimiter) -> int: 

35 index = delimiter.index 

36 total = 0 

37 cursor = index + 1 

38 while cursor: 

39 total += self._tree[cursor] 

40 cursor -= cursor & -cursor 

41 return index - total 

42 

43 def collapse(self, closer: _Delimiter, removed: int) -> None: 

44 if not removed: 

45 return 

46 cursor = closer.index + 1 

47 while cursor < len(self._tree): 

48 self._tree[cursor] += removed 

49 cursor += cursor & -cursor 

50 

51 def deactivate(self, order: int) -> None: 

52 self._next[order] = self._find(order + 1) 

53 

54 def deactivate_range(self, delimiters: List[_Delimiter], start: int, end: int) -> None: 

55 order = self._find(start) 

56 while order < end: 

57 delimiters[order].length = 0 

58 self._next[order] = self._find(order + 1) 

59 order = self._find(order) 

60 

61 def _find(self, order: int) -> int: 

62 root = order 

63 while self._next[root] != root: 

64 root = self._next[root] 

65 while self._next[order] != order: 

66 parent = self._next[order] 

67 self._next[order] = root 

68 order = parent 

69 return root 

70 

71 

72def finalize_emphasis_tokens( 

73 tokens: List[Dict[str, Any]], 

74 enabled: bool, 

75 max_depth: int, 

76) -> List[Dict[str, Any]]: 

77 if not enabled: 

78 return _clean_emphasis_tokens(tokens) 

79 if not _contains_emphasis_marker(tokens): 

80 return _clean_emphasis_tokens(tokens) 

81 

82 parts: List[Dict[str, Any]] = [] 

83 delimiters: List[_Delimiter] = [] 

84 source = _emphasis_source_text(tokens) 

85 source_pos = 0 

86 for token in tokens: 

87 if token["type"] == "text" and token.get("_emphasis", True): 

88 _split_text_token(token, source, source_pos, parts, delimiters) 

89 else: 

90 parts.append(_clean_emphasis_token(token)) 

91 source_pos += _emphasis_source_length(token) 

92 

93 if _process_dense_emphasis(parts, delimiters): 

94 return _merge_text_tokens(parts) 

95 

96 _process_emphasis_delimiters(parts, delimiters, max_depth) 

97 return _merge_text_tokens(parts) 

98 

99 

100def _process_dense_emphasis(parts: List[Dict[str, Any]], delimiters: List[_Delimiter]) -> bool: 

101 """Process a flat run such as ``*a*a*a`` without repeated list scans.""" 

102 if len(delimiters) < 4: 

103 return False 

104 

105 marker = delimiters[0].marker 

106 if marker not in ("*", "_") or len(parts) != len(delimiters) * 2: 

107 return False 

108 

109 for index, delimiter in enumerate(delimiters): 

110 if ( 

111 delimiter.marker != marker 

112 or delimiter.length != 1 

113 or delimiter.index != index * 2 

114 or parts[delimiter.index]["type"] != "text" 

115 or parts[delimiter.index + 1]["type"] != "text" 

116 ): 

117 return False 

118 

119 pair_count = len(delimiters) // 2 

120 processed: List[Dict[str, Any]] = [] 

121 for pair_index in range(pair_count): 

122 opener = delimiters[pair_index * 2] 

123 closer = delimiters[pair_index * 2 + 1] 

124 if ( 

125 not opener.can_open 

126 or not closer.can_close 

127 or not _can_match_emphasis_delimiters(opener, closer) 

128 or not _has_emphasis_content(parts, opener.index + 1, closer.index) 

129 ): 

130 return False 

131 

132 if pair_index: 

133 processed.append(parts[opener.index - 1]) 

134 processed.append( 

135 { 

136 "type": "emphasis", 

137 "children": [parts[opener.index + 1]], 

138 } 

139 ) 

140 

141 if pair_count: 

142 last_close = delimiters[pair_count * 2 - 1].index 

143 parts[:] = processed + parts[last_close + 1 :] 

144 return True 

145 

146 

147def _contains_emphasis_marker(tokens: List[Dict[str, Any]]) -> bool: 

148 for token in tokens: 

149 if token["type"] == "text" and token.get("_emphasis", True): 

150 raw = token["raw"] 

151 if "*" in raw or "_" in raw: 

152 return True 

153 return False 

154 

155 

156def _clean_emphasis_tokens(tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 

157 return [_clean_emphasis_token(token) for token in tokens] 

158 

159 

160def _clean_emphasis_token(token: Dict[str, Any]) -> Dict[str, Any]: 

161 if "_emphasis" not in token: 

162 return token 

163 token = token.copy() 

164 token.pop("_emphasis", None) 

165 return token 

166 

167 

168def _emphasis_source_text(tokens: List[Dict[str, Any]]) -> str: 

169 values = [] 

170 for token in tokens: 

171 if token["type"] == "text": 

172 values.append(token["raw"]) 

173 elif token["type"] in ("softbreak", "linebreak"): 

174 values.append("\n") 

175 else: 

176 values.append("\ufffc") 

177 return "".join(values) 

178 

179 

180def _emphasis_source_length(token: Dict[str, Any]) -> int: 

181 if token["type"] == "text": 

182 return len(token["raw"]) 

183 return 1 

184 

185 

186def _split_text_token( 

187 token: Dict[str, Any], 

188 source: str, 

189 source_start: int, 

190 parts: List[Dict[str, Any]], 

191 delimiters: List[_Delimiter], 

192) -> None: 

193 text = token["raw"] 

194 pos = 0 

195 while pos < len(text): 

196 if text[pos] not in "*_": 

197 end = _next_delimiter_run(text, pos) 

198 parts.append({"type": "text", "raw": text[pos:end]}) 

199 pos = end 

200 continue 

201 

202 marker = text[pos] 

203 end = pos 

204 while end < len(text) and text[end] == marker: 

205 end += 1 

206 length = end - pos 

207 absolute = source_start + pos 

208 can_open = _can_open_emphasis(source, absolute, length, marker) 

209 can_close = _can_close_emphasis(source, absolute, length, marker) 

210 index = len(parts) 

211 parts.append({"type": "text", "raw": text[pos:end]}) 

212 if can_open or can_close: 

213 delimiters.append(_Delimiter(index, marker, length, can_open, can_close, length, len(delimiters))) 

214 pos = end 

215 

216 

217def _next_delimiter_run(text: str, pos: int) -> int: 

218 while pos < len(text) and text[pos] not in "*_": 

219 pos += 1 

220 return pos 

221 

222 

223def _process_emphasis_delimiters( 

224 parts: List[Dict[str, Any]], 

225 delimiters: List[_Delimiter], 

226 max_depth: int, 

227) -> None: 

228 index_map = _DelimiterIndex(delimiters, len(parts)) 

229 closer_pos = 0 

230 openers_bottom: Dict[Tuple[str, int, bool], int] = {} 

231 while closer_pos < len(delimiters): 

232 closer = delimiters[closer_pos] 

233 if not closer.can_close or closer.length == 0: 

234 closer_pos += 1 

235 continue 

236 

237 opener_key = (closer.marker, closer.length % 3, closer.can_open) 

238 opener_pos = closer_pos - 1 

239 opener_bottom = openers_bottom.get(opener_key, 0) 

240 opener = None 

241 while opener_pos >= opener_bottom: 

242 candidate = delimiters[opener_pos] 

243 if ( 

244 candidate.marker == closer.marker 

245 and candidate.can_open 

246 and candidate.length > 0 

247 and _can_match_emphasis_delimiters(candidate, closer) 

248 ): 

249 opener = candidate 

250 break 

251 opener_pos -= 1 

252 

253 if opener is None: 

254 openers_bottom[opener_key] = closer_pos 

255 closer_pos += 1 

256 continue 

257 

258 opener_index = index_map.current(opener) 

259 closer_index = index_map.current(closer) 

260 if opener.length >= 2 and closer.length >= 2: 

261 use_length = 2 

262 else: 

263 use_length = 1 

264 if use_length == 2 and not _has_strong_enabled(parts, opener_index, closer_index): 

265 use_length = 1 

266 if use_length == 1 and not _has_emphasis_enabled(parts, opener_index, closer_index): 

267 closer_pos += 1 

268 continue 

269 if not _has_emphasis_content(parts, opener_index + 1, closer_index): 

270 closer_pos += 1 

271 continue 

272 

273 opener_text = parts[opener_index] 

274 closer_text = parts[closer_index] 

275 if opener_text["type"] != "text" or closer_text["type"] != "text": 

276 closer_pos += 1 

277 continue 

278 

279 children = parts[opener_index + 1 : closer_index] 

280 if max_depth > 0 and _emphasis_depth(children) >= max_depth: 

281 closer_pos += 1 

282 continue 

283 opener_text["raw"] = opener_text["raw"][:-use_length] 

284 closer_text["raw"] = closer_text["raw"][use_length:] 

285 if use_length == 2: 

286 node = {"type": "strong", "children": children} 

287 else: 

288 node = {"type": "emphasis", "children": children} 

289 

290 old_closer_index = closer_index 

291 parts[opener_index + 1 : old_closer_index] = [node] 

292 

293 removed = old_closer_index - opener_index - 2 

294 if removed: 

295 index_map.deactivate_range(delimiters, opener_pos + 1, closer_pos) 

296 index_map.collapse(closer, removed) 

297 

298 opener.length -= use_length 

299 closer.length -= use_length 

300 if opener.length == 0: 

301 opener.can_open = False 

302 index_map.deactivate(opener.order) 

303 if closer.length == 0: 

304 closer.can_close = False 

305 index_map.deactivate(closer.order) 

306 

307 if opener.can_open or closer.can_close: 

308 closer_pos = max(opener_pos, openers_bottom.get(opener_key, 0)) 

309 else: 

310 closer_pos += 1 

311 

312 

313def _emphasis_depth(tokens: List[Dict[str, Any]]) -> int: 

314 max_depth = 0 

315 stack = [(token, 0) for token in tokens] 

316 while stack: 

317 token, depth = stack.pop() 

318 token_type = token["type"] 

319 if token_type in ("emphasis", "strong"): 

320 depth += 1 

321 if depth > max_depth: 

322 max_depth = depth 

323 for child in token.get("children", ()): 

324 stack.append((child, depth)) 

325 return max_depth 

326 

327 

328def _has_strong_enabled(parts: List[Dict[str, Any]], opener_index: int, closer_index: int) -> bool: 

329 return len(_text_raw(parts[opener_index])) >= 2 and len(_text_raw(parts[closer_index])) >= 2 

330 

331 

332def _has_emphasis_enabled(parts: List[Dict[str, Any]], opener_index: int, closer_index: int) -> bool: 

333 return bool(_text_raw(parts[opener_index]) and _text_raw(parts[closer_index])) 

334 

335 

336def _text_raw(token: Dict[str, Any]) -> str: 

337 if token["type"] == "text": 

338 return cast(str, token["raw"]) 

339 return "" 

340 

341 

342def _has_emphasis_content(parts: List[Dict[str, Any]], start: int, end: int) -> bool: 

343 for part in parts[start:end]: 

344 if part["type"] != "text" or part["raw"] != "": 

345 return True 

346 return False 

347 

348 

349def _can_match_emphasis_delimiters(opener: _Delimiter, closer: _Delimiter) -> bool: 

350 if opener.can_close or closer.can_open: 

351 open_len = opener.orig_length 

352 close_len = closer.orig_length 

353 return (open_len + close_len) % 3 != 0 or open_len % 3 == 0 and close_len % 3 == 0 

354 return True 

355 

356 

357def _can_open_emphasis(text: str, start: int, size: int, marker: str) -> bool: 

358 previous = text[start - 1] if start > 0 else "\n" 

359 next_char = text[start + size] if start + size < len(text) else "\n" 

360 if marker == "_" and previous.isalnum() and next_char.isalnum(): 

361 return False 

362 if next_char.isspace(): 

363 return False 

364 if _is_punctuation(next_char) and not previous.isspace() and not _is_punctuation(previous): 

365 return False 

366 return True 

367 

368 

369def _can_close_emphasis(text: str, start: int, size: int, marker: str) -> bool: 

370 previous = text[start - 1] if start > 0 else "\n" 

371 next_char = text[start + size] if start + size < len(text) else "\n" 

372 if marker == "_" and previous.isalnum() and next_char.isalnum(): 

373 return False 

374 if previous.isspace(): 

375 return False 

376 if _is_punctuation(previous) and not next_char.isspace() and not _is_punctuation(next_char): 

377 return False 

378 return True 

379 

380 

381def _is_punctuation(char: str) -> bool: 

382 return not char.isspace() and not char.isalnum() 

383 

384 

385def _merge_text_tokens(tokens: List[Dict[str, Any]]) -> List[Dict[str, Any]]: 

386 result: List[Dict[str, Any]] = [] 

387 for token in tokens: 

388 if token["type"] == "text" and token["raw"] == "": 

389 continue 

390 if token["type"] == "text" and result and result[-1]["type"] == "text": 

391 if not is_entity_boundary(result[-1]["raw"], token["raw"]): 

392 result[-1]["raw"] += token["raw"] 

393 continue 

394 result.append(_clean_emphasis_token(token)) 

395 return result