1from __future__ import annotations
2
3from dataclasses import dataclass
4from typing import TYPE_CHECKING, Any, Literal, NamedTuple
5
6from ..common.utils import isMdAsciiPunct, isPunctChar, isWhiteSpace
7from ..ruler import StateBase
8from ..token import Token
9from ..utils import EnvType
10
11if TYPE_CHECKING:
12 from markdown_it import MarkdownIt
13
14
15@dataclass(slots=True)
16class Delimiter:
17 # Char code of the starting marker (number).
18 marker: int
19
20 # Total length of these series of delimiters.
21 length: int
22
23 # A position of the token this delimiter corresponds to.
24 token: int
25
26 # If this delimiter is matched as a valid opener, `end` will be
27 # equal to its position, otherwise it's `-1`.
28 end: int
29
30 # Boolean flags that determine if this delimiter could open or close
31 # an emphasis.
32 open: bool
33 close: bool
34
35 level: bool | None = None
36
37
38class Scanned(NamedTuple):
39 can_open: bool
40 can_close: bool
41 length: int
42
43
44class StateInline(StateBase):
45 def __init__(
46 self, src: str, md: MarkdownIt, env: EnvType, outTokens: list[Token]
47 ) -> None:
48 self.src = src
49 self.env = env
50 self.md = md
51 self.tokens = outTokens
52 self.tokens_meta: list[dict[str, Any] | None] = [None] * len(outTokens)
53
54 self.pos = 0
55 self.posMax = len(self.src)
56 self.level = 0
57 # `pending` holds literal text not yet flushed to a token. It is
58 # exposed as a plain `str` (see the property below), but is accumulated
59 # through a list buffer so that appending one character at a time -- the
60 # inline tokenizer's fallback path -- stays amortised O(1). Appending
61 # to a `str` *attribute* cannot use CPython's in-place concatenation
62 # optimisation (the attribute holds a second reference), so each `+=`
63 # copies the whole string, making long runs of non-markup characters
64 # quadratic.
65 self._pending = ""
66 self._pending_buffer: list[str] = []
67 self.pendingLevel = 0
68
69 # Stores { start: end } pairs. Useful for backtrack
70 # optimization of pairs parse (emphasis, strikes).
71 self.cache: dict[int, int] = {}
72
73 # List of emphasis-like delimiters for current tag
74 self.delimiters: list[Delimiter] = []
75
76 # Stack of delimiter lists for upper level tags
77 self._prev_delimiters: list[list[Delimiter]] = []
78
79 # backticklength => last seen position
80 self.backticks: dict[int, int] = {}
81 self.backticksScanned = False
82
83 # Counter used to disable inline linkify-it execution
84 # inside <a> and markdown links
85 self.linkLevel = 0
86
87 def __repr__(self) -> str:
88 return (
89 f"{self.__class__.__name__}"
90 f"(pos=[{self.pos} of {self.posMax}], token={len(self.tokens)})"
91 )
92
93 @property
94 def pending(self) -> str:
95 """Literal text accumulated so far, but not yet flushed to a token."""
96 buffer = self._pending_buffer
97 if buffer:
98 # Move the string into a local and drop the instance's reference
99 # before concatenating. With a single reference left, CPython
100 # resizes the string in place (amortised O(new chars)) rather
101 # than copying it, so a rule that reads `pending` on every
102 # character (e.g. an attribute-syntax plugin) stays linear.
103 text = self._pending
104 self._pending = ""
105 text += "".join(buffer)
106 buffer.clear()
107 self._pending = text
108 return self._pending
109
110 @pending.setter
111 def pending(self, value: str) -> None:
112 self._pending = value
113 # Assign rather than `.clear()` so the setter also works on an
114 # instance whose `__init__` has not run yet (subclasses that set
115 # `pending` before calling `super().__init__()`), and so a copied
116 # state never shares a buffer with its original.
117 self._pending_buffer = []
118
119 def __copy__(self) -> StateInline:
120 """Shallow copy that does not share the pending text buffer."""
121 text = self.pending # materialise (and clear) our own buffer first
122 new = self.__class__.__new__(self.__class__)
123 new.__dict__.update(self.__dict__)
124 new._pending = text
125 new._pending_buffer = []
126 return new
127
128 def append_pending(self, text: str) -> None:
129 """Append literal text to `pending`, in amortised O(1) time.
130
131 Prefer this to ``state.pending += text`` on hot paths: it buffers the
132 fragment rather than rebuilding the whole ``pending`` string per call.
133 """
134 self._pending_buffer.append(text)
135
136 def pushPending(self) -> Token:
137 token = Token("text", "", 0)
138 token.content = self.pending
139 token.level = self.pendingLevel
140 self.tokens.append(token)
141 self.pending = ""
142 return token
143
144 def push(self, ttype: str, tag: str, nesting: Literal[-1, 0, 1]) -> Token:
145 """Push new token to "stream".
146 If pending text exists - flush it as text token
147 """
148 if self.pending:
149 self.pushPending()
150
151 token = Token(ttype, tag, nesting)
152 token_meta = None
153
154 if nesting < 0:
155 # closing tag
156 self.level -= 1
157 self.delimiters = self._prev_delimiters.pop()
158
159 token.level = self.level
160
161 if nesting > 0:
162 # opening tag
163 self.level += 1
164 self._prev_delimiters.append(self.delimiters)
165 self.delimiters = []
166 token_meta = {"delimiters": self.delimiters}
167
168 self.pendingLevel = self.level
169 self.tokens.append(token)
170 self.tokens_meta.append(token_meta)
171 return token
172
173 def scanDelims(self, start: int, canSplitWord: bool) -> Scanned:
174 """
175 Scan a sequence of emphasis-like markers, and determine whether
176 it can start an emphasis sequence or end an emphasis sequence.
177
178 - start - position to scan from (it should point at a valid marker);
179 - canSplitWord - determine if these markers can be found inside a word
180
181 """
182 pos = start
183 maximum = self.posMax
184 marker = self.src[start]
185
186 # treat beginning of the line as a whitespace
187 lastChar = self.src[start - 1] if start > 0 else " "
188
189 while pos < maximum and self.src[pos] == marker:
190 pos += 1
191
192 count = pos - start
193
194 # treat end of the line as a whitespace
195 nextChar = self.src[pos] if pos < maximum else " "
196
197 isLastPunctChar = isMdAsciiPunct(ord(lastChar)) or isPunctChar(lastChar)
198 isNextPunctChar = isMdAsciiPunct(ord(nextChar)) or isPunctChar(nextChar)
199
200 isLastWhiteSpace = isWhiteSpace(ord(lastChar))
201 isNextWhiteSpace = isWhiteSpace(ord(nextChar))
202
203 left_flanking = not (
204 isNextWhiteSpace
205 or (isNextPunctChar and not (isLastWhiteSpace or isLastPunctChar))
206 )
207 right_flanking = not (
208 isLastWhiteSpace
209 or (isLastPunctChar and not (isNextWhiteSpace or isNextPunctChar))
210 )
211
212 can_open = left_flanking and (
213 canSplitWord or (not right_flanking) or isLastPunctChar
214 )
215 can_close = right_flanking and (
216 canSplitWord or (not left_flanking) or isNextPunctChar
217 )
218
219 return Scanned(can_open, can_close, count)