Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/mdit_py_plugins/field_list/__init__.py: 100%

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

123 statements  

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