1"""Field list plugin"""
2
3from contextlib import contextmanager
4from typing import Iterator, Optional, Tuple
5
6from markdown_it import MarkdownIt
7from markdown_it.rules_block import StateBlock
8
9from mdit_py_plugins.utils import is_code_block
10
11
12def fieldlist_plugin(md: MarkdownIt) -> None:
13 """Field lists are mappings from field names to field bodies, based on the
14 `reStructureText syntax
15 <https://docutils.sourceforge.io/docs/ref/rst/restructuredtext.html#field-lists>`_.
16
17 .. code-block:: md
18
19 :name *markup*:
20 :name1: body content
21 :name2: paragraph 1
22
23 paragraph 2
24 :name3:
25 paragraph 1
26
27 paragraph 2
28
29 A field name may consist of any characters except colons (":").
30 Inline markup is parsed in field names.
31
32 The field name is followed by whitespace and the field body.
33 The field body may be empty or contain multiple body elements.
34
35 Since the field marker may be quite long,
36 the second and subsequent lines of the field body do not have to
37 line up with the first line, but they must be indented relative to the
38 field name marker, and they must line up with each other.
39 """
40 md.block.ruler.before(
41 "paragraph",
42 "fieldlist",
43 _fieldlist_rule,
44 {"alt": ["paragraph", "reference", "blockquote"]},
45 )
46
47
48def parseNameMarker(state: StateBlock, startLine: int) -> Tuple[int, str]:
49 """Parse field name: `:name:`
50
51 :returns: position after name marker, name text
52 """
53 start = state.bMarks[startLine] + state.tShift[startLine]
54 pos = start
55 maximum = state.eMarks[startLine]
56
57 # marker should have at least 3 chars (colon + character + colon)
58 if pos + 2 >= maximum:
59 return -1, ""
60
61 # first character should be ':'
62 if state.src[pos] != ":":
63 return -1, ""
64
65 # scan name length
66 name_length = 1
67 found_close = False
68 for ch in state.src[pos + 1 :]:
69 if ch == "\n":
70 break
71 if ch == ":":
72 # TODO backslash escapes
73 found_close = True
74 break
75 name_length += 1
76
77 if not found_close:
78 return -1, ""
79
80 # get name
81 name_text = state.src[pos + 1 : pos + name_length]
82
83 # name should contain at least one character
84 if not name_text.strip():
85 return -1, ""
86
87 return pos + name_length + 1, name_text
88
89
90@contextmanager
91def set_parent_type(state: StateBlock, name: str) -> Iterator[None]:
92 """Temporarily set parent type to `name`"""
93 oldParentType = state.parentType
94 state.parentType = name
95 yield
96 state.parentType = oldParentType
97
98
99def _fieldlist_rule(
100 state: StateBlock, startLine: int, endLine: int, silent: bool
101) -> bool:
102 # adapted from markdown_it/rules_block/list.py::list_block
103
104 if is_code_block(state, startLine):
105 return False
106
107 posAfterName, name_text = parseNameMarker(state, startLine)
108 if posAfterName < 0:
109 return False
110
111 # For validation mode we can terminate immediately
112 if silent:
113 return True
114
115 # start field list
116 token = state.push("field_list_open", "dl", 1)
117 token.attrSet("class", "field-list")
118 token.map = listLines = [startLine, 0]
119
120 # iterate list items
121 nextLine = startLine
122
123 with set_parent_type(state, "fieldlist"):
124 while nextLine < endLine:
125 # create name tokens
126 token = state.push("fieldlist_name_open", "dt", 1)
127 token.map = [startLine, startLine]
128 token = state.push("inline", "", 0)
129 token.map = [startLine, startLine]
130 token.content = name_text
131 token.children = []
132 token = state.push("fieldlist_name_close", "dt", -1)
133
134 # set indent positions
135 pos = posAfterName
136 maximum: int = state.eMarks[nextLine]
137 first_line_body_indent = (
138 state.sCount[nextLine]
139 + posAfterName
140 - (state.bMarks[startLine] + state.tShift[startLine])
141 )
142
143 # find indent to start of body on first line
144 while pos < maximum:
145 ch = state.src[pos]
146
147 if ch == "\t":
148 first_line_body_indent += (
149 4 - (first_line_body_indent + state.bsCount[nextLine]) % 4
150 )
151 elif ch == " ":
152 first_line_body_indent += 1
153 else:
154 break
155
156 pos += 1
157
158 contentStart = pos
159
160 # to figure out the indent of the body,
161 # we look at all non-empty, indented lines and find the minimum indent
162 block_indent: Optional[int] = None
163 _line = startLine + 1
164 while _line < endLine:
165 # if start_of_content < end_of_content, then non-empty line
166 if (state.bMarks[_line] + state.tShift[_line]) < state.eMarks[_line]:
167 if state.tShift[_line] <= 0:
168 # the line has no indent, so it's the end of the field
169 break
170 block_indent = (
171 state.tShift[_line]
172 if block_indent is None
173 else min(block_indent, state.tShift[_line])
174 )
175
176 _line += 1
177
178 has_first_line = contentStart < maximum
179 if block_indent is None: # no body content
180 if not has_first_line: # noqa: SIM108
181 # no body or first line, so just use default
182 block_indent = 2
183 else:
184 # only a first line, so use it's indent
185 block_indent = first_line_body_indent
186 else:
187 block_indent = min(block_indent, first_line_body_indent)
188
189 # Run subparser on the field body
190 token = state.push("fieldlist_body_open", "dd", 1)
191 token.map = [startLine, startLine]
192
193 with temp_state_changes(state, startLine):
194 diff = 0
195 if has_first_line and block_indent < first_line_body_indent:
196 # this is a hack to get the first line to render correctly
197 # we temporarily "shift" it to the left by the difference
198 # between the first line indent and the block indent
199 # and replace the "hole" left with space,
200 # so that src indexes still match
201 diff = first_line_body_indent - block_indent
202 state.src = (
203 state.src[: contentStart - diff]
204 + " " * diff
205 + state.src[contentStart:]
206 )
207
208 state.tShift[startLine] = contentStart - diff - state.bMarks[startLine]
209 state.sCount[startLine] = first_line_body_indent - diff
210 state.blkIndent = block_indent
211
212 state.md.block.tokenize(state, startLine, endLine)
213
214 state.push("fieldlist_body_close", "dd", -1)
215
216 nextLine = startLine = state.line
217 token.map[1] = nextLine
218
219 if nextLine >= endLine:
220 break
221
222 contentStart = state.bMarks[startLine]
223
224 # Try to check if list is terminated or continued.
225 if state.sCount[nextLine] < state.blkIndent:
226 break
227
228 if is_code_block(state, startLine):
229 break
230
231 # get next field item
232 posAfterName, name_text = parseNameMarker(state, startLine)
233 if posAfterName < 0:
234 break
235
236 # Finalize list
237 token = state.push("field_list_close", "dl", -1)
238 listLines[1] = nextLine
239 state.line = nextLine
240
241 return True
242
243
244@contextmanager
245def temp_state_changes(state: StateBlock, startLine: int) -> Iterator[None]:
246 """Allow temporarily changing certain state attributes."""
247 oldTShift = state.tShift[startLine]
248 oldSCount = state.sCount[startLine]
249 oldBlkIndent = state.blkIndent
250 oldSrc = state.src
251 yield
252 state.blkIndent = oldBlkIndent
253 state.tShift[startLine] = oldTShift
254 state.sCount[startLine] = oldSCount
255 state.src = oldSrc