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)