1"""Field list plugin"""
2
3from collections.abc import Iterator
4from contextlib import contextmanager
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: int | None = 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] <= state.blkIndent:
168 # the line is not indented relative to the field marker,
169 # so it's the end of the field body
170 break
171 block_indent = (
172 state.tShift[_line]
173 if block_indent is None
174 else min(block_indent, state.tShift[_line])
175 )
176
177 _line += 1
178
179 has_first_line = contentStart < maximum
180 if block_indent is None: # no body content
181 if not has_first_line: # noqa: SIM108
182 # no body or first line, so just use default
183 block_indent = 2
184 else:
185 # only a first line, so use it's indent
186 block_indent = first_line_body_indent
187 else:
188 block_indent = min(block_indent, first_line_body_indent)
189
190 # Run subparser on the field body
191 token = state.push("fieldlist_body_open", "dd", 1)
192 token.map = [startLine, startLine]
193
194 with temp_state_changes(state, startLine):
195 diff = 0
196 if has_first_line and block_indent < first_line_body_indent:
197 # this is a hack to get the first line to render correctly
198 # we temporarily "shift" it to the left by the difference
199 # between the first line indent and the block indent
200 # and replace the "hole" left with space,
201 # so that src indexes still match
202 diff = first_line_body_indent - block_indent
203 state.src = (
204 state.src[: contentStart - diff]
205 + " " * diff
206 + state.src[contentStart:]
207 )
208
209 state.tShift[startLine] = contentStart - diff - state.bMarks[startLine]
210 state.sCount[startLine] = first_line_body_indent - diff
211 state.blkIndent = block_indent
212
213 state.md.block.tokenize(state, startLine, endLine)
214
215 state.push("fieldlist_body_close", "dd", -1)
216
217 nextLine = startLine = state.line
218 token.map[1] = nextLine
219
220 if nextLine >= endLine:
221 break
222
223 contentStart = state.bMarks[startLine]
224
225 # Try to check if list is terminated or continued.
226 if state.sCount[nextLine] < state.blkIndent:
227 break
228
229 if is_code_block(state, startLine):
230 break
231
232 # get next field item
233 posAfterName, name_text = parseNameMarker(state, startLine)
234 if posAfterName < 0:
235 break
236
237 # Finalize list
238 token = state.push("field_list_close", "dl", -1)
239 listLines[1] = nextLine
240 state.line = nextLine
241
242 return True
243
244
245@contextmanager
246def temp_state_changes(state: StateBlock, startLine: int) -> Iterator[None]:
247 """Allow temporarily changing certain state attributes."""
248 oldTShift = state.tShift[startLine]
249 oldSCount = state.sCount[startLine]
250 oldBlkIndent = state.blkIndent
251 oldSrc = state.src
252 yield
253 state.blkIndent = oldBlkIndent
254 state.tShift[startLine] = oldTShift
255 state.sCount[startLine] = oldSCount
256 state.src = oldSrc