Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/mistune/inline_parser.py: 74%

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

272 statements  

1from __future__ import annotations 

2 

3import re 

4from typing import Any, Dict, Iterable, List, Match, MutableMapping, Optional, Set, Tuple, Union 

5 

6from ._inline.emphasis import finalize_emphasis_tokens 

7from ._inline.links import parse_link_close, parse_link_open 

8from .core import DEFAULT_MAX_NESTED_LEVEL, InlineState, Parser 

9from .helpers import HTML_ATTRIBUTES, HTML_TAGNAME, PUNCTUATION, unescape_char 

10from .util import escape_url 

11 

12DEFAULT_MAX_EMPHASIS_DEPTH = DEFAULT_MAX_NESTED_LEVEL 

13DEFAULT_MAX_IMAGE_DEPTH = DEFAULT_MAX_NESTED_LEVEL 

14 

15_KNOWN_RULE_START_CHARS = { 

16 "escape": {"\\"}, 

17 "codespan": {"`"}, 

18 "emphasis": {"*", "_"}, 

19 "link": {"!", "["}, 

20 "link_close": {"]"}, 

21 "auto_link": {"<"}, 

22 "auto_email": {"<"}, 

23 "inline_html": {"<"}, 

24 "linebreak": {"\n"}, 

25 "softbreak": {"\n"}, 

26 "prec_auto_link": {"<"}, 

27 "prec_inline_html": {"<"}, 

28 # built-in plugins 

29 "url_link": {"h"}, 

30 "strikethrough": {"~"}, 

31 "mark": {"="}, 

32 "insert": {"^"}, 

33 "superscript": {"^"}, 

34 "subscript": {"~"}, 

35 "footnote": {"["}, 

36 "inline_math": {"$"}, 

37 "ruby": {"["}, 

38 "inline_spoiler": {">"}, 

39} 

40 

41AUTO_EMAIL = ( 

42 r"""<[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9]""" 

43 r"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" 

44 r"(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>" 

45) 

46 

47_HTML_DECLARATION_START = re.compile(r"<![A-Z]+[ \t\n\r\f]") 

48_INLINE_HTML_TAG = re.compile(r"<" + HTML_TAGNAME + HTML_ATTRIBUTES + r"\s*/?>|</" + HTML_TAGNAME + r"\s*>") 

49 

50 

51class InlineParser(Parser[InlineState]): 

52 sc_flag = 0 

53 state_cls = InlineState 

54 

55 #: linebreak leaves two spaces at the end of line 

56 STD_LINEBREAK = r"(?:\\| {2,})\n\s*" 

57 

58 #: every new line becomes <br> 

59 HARD_LINEBREAK = r" *\n\s*" 

60 

61 # we only need to find the start pattern of an inline token 

62 SPECIFICATION = { 

63 # e.g. \`, \$ 

64 "escape": r"(?:\\" + PUNCTUATION + ")+", 

65 # `code, ```code 

66 "codespan": r"`{1,}", 

67 # *w, **w, _w, __w 

68 "emphasis": r"[*_]+", 

69 # [link], ![img] 

70 "link": r"!?\[", 

71 "link_close": r"\]+", 

72 # <https://example.com>. regex copied from commonmark.js 

73 "auto_link": r"<[A-Za-z][A-Za-z0-9.+-]{1,31}:[^<>\x00-\x20]*>", 

74 "auto_email": AUTO_EMAIL, 

75 "inline_html": r"<(?:/?[A-Za-z]|!|\?)", 

76 "linebreak": STD_LINEBREAK, 

77 "softbreak": HARD_LINEBREAK, 

78 "prec_auto_link": r"<[A-Za-z][A-Za-z\d.+-]{1,31}:", 

79 "prec_inline_html": r"</?" + HTML_TAGNAME + r"|<!|<\?", 

80 } 

81 DEFAULT_RULES = ( 

82 "escape", 

83 "codespan", 

84 "emphasis", 

85 "link", 

86 "link_close", 

87 "auto_link", 

88 "auto_email", 

89 "inline_html", 

90 "linebreak", 

91 ) 

92 

93 def __init__( 

94 self, 

95 hard_wrap: bool = False, 

96 max_emphasis_depth: Optional[int] = None, 

97 max_image_depth: Optional[int] = None, 

98 *, 

99 max_nested_level: int = DEFAULT_MAX_NESTED_LEVEL, 

100 ) -> None: 

101 super(InlineParser, self).__init__() 

102 

103 self.hard_wrap = hard_wrap 

104 # Older keyword names configure the same bound, rather than independent 

105 # per-token budgets that can be bypassed by alternating syntax types. 

106 limits = [max_nested_level] + [value for value in (max_emphasis_depth, max_image_depth) if value is not None] 

107 self.max_nested_level = min(limits) 

108 self._fast_trigger_chars: Optional[Set[str]] = None 

109 self._fast_trigger_re: Optional[re.Pattern[str]] = None 

110 self._fast_trigger_re_chars: Optional[Tuple[str, ...]] = None 

111 self._rule_start_chars: Dict[str, Set[str]] = {} 

112 # lazy add linebreak 

113 if hard_wrap: 

114 self.specification["linebreak"] = self.HARD_LINEBREAK 

115 else: 

116 self.rules.append("softbreak") 

117 

118 self._methods = {name: getattr(self, "parse_" + name) for name in self.rules} 

119 

120 @property 

121 def max_nested_level(self) -> int: 

122 return self._max_nested_level 

123 

124 @max_nested_level.setter 

125 def max_nested_level(self, value: int) -> None: 

126 if value < 1: 

127 raise ValueError("max_nested_level must be positive") 

128 self._max_nested_level = value 

129 

130 @property 

131 def max_emphasis_depth(self) -> int: 

132 return self.max_nested_level 

133 

134 @max_emphasis_depth.setter 

135 def max_emphasis_depth(self, value: int) -> None: 

136 self.max_nested_level = value 

137 

138 @property 

139 def max_image_depth(self) -> int: 

140 return self.max_nested_level 

141 

142 @max_image_depth.setter 

143 def max_image_depth(self, value: int) -> None: 

144 self.max_nested_level = value 

145 

146 def register( 

147 self, 

148 name: str, 

149 pattern: Optional[str], 

150 func: Any, 

151 before: Optional[str] = None, 

152 *, 

153 start_chars: Optional[Union[str, Iterable[str]]] = None, 

154 ) -> None: 

155 """Register an inline rule with optional exhaustive start characters.""" 

156 self._rule_start_chars.pop(name, None) 

157 if start_chars is not None: 

158 self._rule_start_chars[name] = _normalize_start_chars(start_chars) 

159 super().register(name, pattern, func, before=before) 

160 self._fast_trigger_chars = None 

161 self._fast_trigger_re = None 

162 self._fast_trigger_re_chars = None 

163 

164 def parse_escape(self, m: Match[str], state: InlineState) -> int: 

165 text = m.group(0) 

166 text = unescape_char(text) 

167 self.process_text(text, state, parse_emphasis=False) 

168 return m.end() 

169 

170 def parse_link(self, m: Match[str], state: InlineState) -> Optional[int]: 

171 return parse_link_open(self, m, state) 

172 

173 def parse_link_close(self, m: Match[str], state: InlineState) -> int: 

174 return parse_link_close(self, m, state) 

175 

176 def parse_auto_link(self, m: Match[str], state: InlineState) -> int: 

177 text = m.group(0) 

178 pos = m.end() 

179 if state.in_link or state.depth >= self.max_nested_level: 

180 self.process_text(text, state) 

181 return pos 

182 

183 text = text[1:-1] 

184 self._add_auto_link(text, text, state) 

185 return pos 

186 

187 def parse_auto_email(self, m: Match[str], state: InlineState) -> int: 

188 text = m.group(0) 

189 pos = m.end() 

190 if state.in_link or state.depth >= self.max_nested_level: 

191 self.process_text(text, state) 

192 return pos 

193 

194 text = text[1:-1] 

195 url = "mailto:" + text 

196 self._add_auto_link(url, text, state) 

197 return pos 

198 

199 def _add_auto_link(self, url: str, text: str, state: InlineState) -> None: 

200 state.append_token( 

201 { 

202 "type": "link", 

203 "children": [{"type": "text", "raw": text}], 

204 "attrs": {"url": escape_url(url)}, 

205 "_autolink": "<" + text + ">", 

206 } 

207 ) 

208 

209 def parse_emphasis(self, m: Match[str], state: InlineState) -> int: 

210 # Delimiter flanking and matching are resolved together after scanning. 

211 self.process_text(m.group(0), state) 

212 return m.end() 

213 

214 def parse_codespan(self, m: Match[str], state: InlineState) -> int: 

215 marker = m.group(0) 

216 pos = m.end() 

217 end = state.delimiters.code_end(marker, pos) 

218 if end is None: 

219 state.append_token({"type": "text", "raw": marker}) 

220 return pos 

221 code = state.src[pos:end].replace("\n", " ") 

222 if code.strip(" ") and code.startswith(" ") and code.endswith(" "): 

223 code = code[1:-1] 

224 state.append_token({"type": "codespan", "raw": code}) 

225 return end + len(marker) 

226 

227 def parse_linebreak(self, m: Match[str], state: InlineState) -> int: 

228 state.append_token({"type": "linebreak"}) 

229 return m.end() 

230 

231 def parse_softbreak(self, m: Match[str], state: InlineState) -> int: 

232 state.append_token({"type": "softbreak"}) 

233 return m.end() 

234 

235 def parse_inline_html(self, m: Match[str], state: InlineState) -> Optional[int]: 

236 start = m.start() 

237 src = state.src 

238 if src.startswith("<!-->", start): 

239 end_pos = start + 5 

240 elif src.startswith("<!--->", start): 

241 end_pos = start + 6 

242 else: 

243 marker = None 

244 offset = 0 

245 if src.startswith("<!--", start): 

246 marker, offset = "-->", 4 

247 elif src.startswith("<?", start): 

248 marker, offset = "?>", 2 

249 elif src.startswith("<![CDATA[", start): 

250 marker, offset = "]]>", 9 

251 elif src.startswith("<!", start): 

252 declaration = _HTML_DECLARATION_START.match(src, start) 

253 if declaration is None: 

254 return None 

255 marker, offset = ">", declaration.end() - start 

256 if marker is not None: 

257 close = state.delimiters.find(marker, start + offset) 

258 if close is None: 

259 return None 

260 end_pos = close + len(marker) 

261 else: 

262 tag = _INLINE_HTML_TAG.match(src, start) 

263 if tag is None: 

264 return None 

265 end_pos = tag.end() 

266 html = src[start:end_pos] 

267 state.append_token({"type": "inline_html", "raw": html}) 

268 if html.startswith(("<a ", "<a>", "<A ", "<A>")): 

269 state.in_link = True 

270 elif html.startswith(("</a ", "</a>", "</A ", "</A>")): 

271 state.in_link = False 

272 return end_pos 

273 

274 def process_text(self, text: str, state: InlineState, parse_emphasis: bool = True) -> None: 

275 token: Dict[str, Any] = {"type": "text", "raw": text} 

276 if not parse_emphasis: 

277 token["_emphasis"] = False 

278 state.append_token(token) 

279 

280 def parse(self, state: InlineState) -> List[Dict[str, Any]]: 

281 pos = 0 

282 sc = self.compile_sc() 

283 while pos < len(state.src): 

284 fast_end = self._find_fast_text_end(state.src, pos) 

285 if fast_end is None: 

286 m = sc.search(state.src, pos) 

287 else: 

288 if fast_end > pos: 

289 self.process_text(state.src[pos:fast_end], state) 

290 pos = fast_end 

291 if pos >= len(state.src): 

292 break 

293 m = sc.match(state.src, pos) 

294 

295 if not m: 

296 if fast_end is not None: 

297 self.process_text(state.src[pos : pos + 1], state) 

298 pos += 1 

299 continue 

300 break 

301 

302 end_pos = m.start() 

303 if end_pos > pos: 

304 hole = state.src[pos:end_pos] 

305 self.process_text(hole, state) 

306 

307 new_pos = self.parse_method(m, state) 

308 if not new_pos: 

309 # move cursor 1 character forward 

310 pos = end_pos + 1 

311 hole = state.src[end_pos:pos] 

312 self.process_text(hole, state) 

313 else: 

314 pos = new_pos 

315 

316 if pos == 0: 

317 # special case, just pure text 

318 self.process_text(state.src, state) 

319 elif pos < len(state.src): 

320 self.process_text(state.src[pos:], state) 

321 state.tokens = self.finalize_tokens(state.tokens, state.depth) 

322 return state.tokens 

323 

324 def finalize_tokens(self, tokens: List[Dict[str, Any]], depth: int) -> List[Dict[str, Any]]: 

325 remaining = self.max_nested_level - depth 

326 return finalize_emphasis_tokens(tokens, "emphasis" in self.rules and remaining > 0, remaining) 

327 

328 def _find_fast_text_end(self, src: str, pos: int) -> Optional[int]: 

329 chars = self._get_fast_trigger_chars() 

330 if chars is None: 

331 return None 

332 

333 trigger_re = self._get_fast_trigger_re(chars) 

334 m = trigger_re.search(src, pos) 

335 if m is None: 

336 return len(src) 

337 

338 if m.group(0) == "\n": 

339 return self._find_linebreak_start(src, pos, m.start()) 

340 return m.start() 

341 

342 def _get_fast_trigger_re(self, chars: Set[str]) -> re.Pattern[str]: 

343 key = tuple(sorted(chars)) 

344 if self._fast_trigger_re is None or self._fast_trigger_re_chars != key: 

345 pattern = "[" + re.escape("".join(key)) + "]" 

346 self._fast_trigger_re = re.compile(pattern) 

347 self._fast_trigger_re_chars = key 

348 assert self._fast_trigger_re is not None 

349 return self._fast_trigger_re 

350 

351 def _find_linebreak_start(self, src: str, min_pos: int, newline_pos: int) -> int: 

352 pos = newline_pos 

353 while pos > min_pos and src[pos - 1] == " ": 

354 pos -= 1 

355 if pos == newline_pos and pos > min_pos and src[pos - 1] == "\\": 

356 return pos - 1 

357 return pos 

358 

359 def _get_fast_trigger_chars(self) -> Optional[Set[str]]: 

360 chars = self._fast_trigger_chars 

361 if chars is not None: 

362 return chars 

363 

364 chars = set() 

365 for name in self.rules: 

366 rule_chars = self._rule_start_chars.get(name, _KNOWN_RULE_START_CHARS.get(name)) 

367 if rule_chars is None: 

368 self._fast_trigger_chars = None 

369 return None 

370 chars.update(rule_chars) 

371 self._fast_trigger_chars = chars 

372 return chars 

373 

374 def precedence_scan( 

375 self, 

376 m: Match[str], 

377 state: InlineState, 

378 end_pos: int, 

379 rules: Optional[List[str]] = None, 

380 ) -> Optional[int]: 

381 if rules is None: 

382 rules = ["codespan", "link", "prec_auto_link", "prec_inline_html"] 

383 

384 mark_pos = m.end() 

385 sc = self.compile_sc(rules) 

386 m1 = sc.search(state.src, mark_pos, end_pos) 

387 if not m1: 

388 return None 

389 

390 lastgroup = m1.lastgroup 

391 if not lastgroup: 

392 return None 

393 rule_name = lastgroup.replace("prec_", "") 

394 sc = self.compile_sc([rule_name]) 

395 m2 = sc.match(state.src, m1.start()) 

396 if not m2: 

397 return None 

398 

399 func = self._methods[rule_name] 

400 new_state = state.copy() 

401 new_state.src = state.src 

402 m2_pos = func(m2, new_state) 

403 if not m2_pos or m2_pos < end_pos: 

404 return None 

405 

406 raw_text = state.src[m.start() : m2.start()] 

407 state.append_token({"type": "text", "raw": raw_text}) 

408 for token in new_state.tokens: 

409 state.append_token(token) 

410 return m2_pos 

411 

412 def render(self, state: InlineState) -> List[Dict[str, Any]]: 

413 self.parse(state) 

414 return state.tokens 

415 

416 def __call__(self, s: str, env: MutableMapping[str, Any]) -> List[Dict[str, Any]]: 

417 state = self.state_cls(env) 

418 state.src = s 

419 return self.render(state) 

420 

421 

422def _normalize_start_chars(start_chars: Union[str, Iterable[str]]) -> Set[str]: 

423 if isinstance(start_chars, str): 

424 values = [start_chars] 

425 else: 

426 values = list(start_chars) 

427 

428 if not values or any(not isinstance(value, str) or len(value) != 1 for value in values): 

429 raise ValueError("start_chars must contain one or more single-character strings") 

430 return set(values)