Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 85 additions & 29 deletions mdit_py_plugins/field_list/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Field list plugin"""
from contextlib import contextmanager
from typing import Tuple
from typing import Optional, Tuple

from markdown_it import MarkdownIt
from markdown_it.rules_block import StateBlock
Expand Down Expand Up @@ -28,8 +28,11 @@ def fieldlist_plugin(md: MarkdownIt):

The field name is followed by whitespace and the field body.
The field body may be empty or contain multiple body elements.
The field body is aligned either by the start of the body on the first line or,
if no body content is on the first line, by 2 spaces.

Since the field marker may be quite long,
the second and subsequent lines of the field body do not have to
line up with the first line, but they must be indented relative to the
field name marker, and they must line up with each other.
"""
md.block.ruler.before(
"paragraph",
Expand Down Expand Up @@ -126,8 +129,8 @@ def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: boo

# set indent positions
pos = posAfterName
maximum = state.eMarks[nextLine]
offset = (
maximum: int = state.eMarks[nextLine]
first_line_body_indent = (
state.sCount[nextLine]
+ posAfterName
- (state.bMarks[startLine] + state.tShift[startLine])
Expand All @@ -138,44 +141,81 @@ def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: boo
ch = state.srcCharCode[pos]

if ch == 0x09: # \t
offset += 4 - (offset + state.bsCount[nextLine]) % 4
first_line_body_indent += (
4 - (first_line_body_indent + state.bsCount[nextLine]) % 4
)
elif ch == 0x20: # \s
offset += 1
first_line_body_indent += 1
else:
break

pos += 1

contentStart = pos

# set indent for body text
# no body on first line, so use constant indentation
# TODO adapt to indentation of subsequent lines?
indent = 2 if contentStart >= maximum else offset
# to figure out the indent of the body,
# we look at all non-empty, indented lines and find the minimum indent
block_indent: Optional[int] = None
_line = startLine + 1
while _line < endLine:
# if start_of_content < end_of_content, then non-empty line
if (state.bMarks[_line] + state.tShift[_line]) < state.eMarks[_line]:
if state.tShift[_line] <= 0:
# the line has no indent, so it's the end of the field
break
block_indent = (
state.tShift[_line]
if block_indent is None
else min(block_indent, state.tShift[_line])
)

_line += 1

has_first_line = contentStart < maximum
if block_indent is None: # no body content
if not has_first_line: # noqa SIM108
# no body or first line, so just use default
block_indent = 2
else:
# only a first line, so use it's indent
block_indent = first_line_body_indent
else:
block_indent = min(block_indent, first_line_body_indent)

# Run subparser on the field body
token = state.push("fieldlist_body_open", "dd", 1)
token.map = itemLines = [startLine, 0]

# change current state, then restore it after parser subcall
oldTShift = state.tShift[startLine]
oldSCount = state.sCount[startLine]
oldBlkIndent = state.blkIndent

state.tShift[startLine] = contentStart - state.bMarks[startLine]
state.sCount[startLine] = offset
state.blkIndent = indent

state.md.block.tokenize(state, startLine, endLine)

state.blkIndent = oldBlkIndent
state.tShift[startLine] = oldTShift
state.sCount[startLine] = oldSCount
token.map = [startLine, startLine]

token = state.push("fieldlist_body_close", "dd", -1)
with temp_state_changes(state, startLine):
diff = 0
if has_first_line and block_indent < first_line_body_indent:
# this is a hack to get the first line to render correctly
# we temporarily "shift" it to the left by the difference
# between the first line indent and the block indent
# and replace the "hole" left with space,
# so that src indexes still match
diff = first_line_body_indent - block_indent
state._src = (
state.src[: contentStart - diff]
+ " " * diff
+ state.src[contentStart:]
)
state.srcCharCode = (
state.srcCharCode[: contentStart - diff]
+ tuple([0x20] * diff)
+ state.srcCharCode[contentStart:]
)

state.tShift[startLine] = contentStart - diff - state.bMarks[startLine]
state.sCount[startLine] = first_line_body_indent - diff
state.blkIndent = block_indent

state.md.block.tokenize(state, startLine, endLine)

state.push("fieldlist_body_close", "dd", -1)

nextLine = startLine = state.line
itemLines[1] = nextLine
token.map[1] = nextLine

if nextLine >= endLine:
break
Expand All @@ -201,3 +241,19 @@ def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: boo
state.line = nextLine

return True


@contextmanager
def temp_state_changes(state: StateBlock, startLine: int):
"""Allow temporarily changing certain state attributes."""
oldTShift = state.tShift[startLine]
oldSCount = state.sCount[startLine]
oldBlkIndent = state.blkIndent
oldSrc = state._src
oldSrcCharCode = state.srcCharCode
yield
state.blkIndent = oldBlkIndent
state.tShift[startLine] = oldTShift
state.sCount[startLine] = oldSCount
state._src = oldSrc
state.srcCharCode = oldSrcCharCode
119 changes: 116 additions & 3 deletions tests/fixtures/field_list.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,47 @@
Docutils example
.
:Date: 2001-08-16
:Version: 1
:Authors: - Me
- Myself
- I
:Indentation: Since the field marker may be quite long, the second
and subsequent lines of the field body do not have to line up
with the first line, but they must be indented relative to the
field name marker, and they must line up with each other.
:Parameter i: integer
.
<dl class="field-list">
<dt>Date</dt>
<dd>
<p>2001-08-16</p>
</dd>
<dt>Version</dt>
<dd>
<p>1</p>
</dd>
<dt>Authors</dt>
<dd>
<ul>
<li>Me</li>
<li>Myself</li>
<li>I</li>
</ul>
</dd>
<dt>Indentation</dt>
<dd>
<p>Since the field marker may be quite long, the second
and subsequent lines of the field body do not have to line up
with the first line, but they must be indented relative to the
field name marker, and they must line up with each other.</p>
</dd>
<dt>Parameter i</dt>
<dd>
<p>integer</p>
</dd>
</dl>
.

Body alignment:
.
:no body:
Expand All @@ -11,6 +55,12 @@ Body alignment:

paragraph 3

:body less: paragraph 1

paragraph 2

paragraph 3

:body on 2nd line:
paragraph 1

Expand Down Expand Up @@ -40,6 +90,12 @@ running onto new line</p>
<p>paragraph 2</p>
<p>paragraph 3</p>
</dd>
<dt>body less</dt>
<dd>
<p>paragraph 1</p>
<p>paragraph 2</p>
<p>paragraph 3</p>
</dd>
<dt>body on 2nd line</dt>
<dd>
<p>paragraph 1</p>
Expand All @@ -53,6 +109,24 @@ running onto new line</p>
</dl>
.

choose smallest indent
.
:name: a

b

c
.
<dl class="field-list">
<dt>name</dt>
<dd>
<p>a</p>
<p>b</p>
<p>c</p>
</dd>
</dl>
.

Empty name:
.
::
Expand Down Expand Up @@ -118,16 +192,15 @@ Body list:
Body code block
.
:name:
code
not code
:name: body

code
.
<dl class="field-list">
<dt>name</dt>
<dd>
<pre><code>code
</code></pre>
<p>not code</p>
</dd>
<dt>name</dt>
<dd>
Expand Down Expand Up @@ -190,6 +263,13 @@ Following blocks:
```python
code
```
:name: body
more

more
trailing

other
.
<dl class="field-list">
<dt>name</dt>
Expand Down Expand Up @@ -217,6 +297,16 @@ code
</dl>
<pre><code class="language-python">code
</code></pre>
<dl class="field-list">
<dt>name</dt>
<dd>
<p>body
more</p>
<p>more
trailing</p>
</dd>
</dl>
<p>other</p>
.

In list:
Expand All @@ -240,13 +330,36 @@ In list:
In blockquote:
.
> :name: body
> :name: body
> other
> :name: body
>
> other
> :name: body
>
> other
.
<blockquote>
<dl class="field-list">
<dt>name</dt>
<dd>
<p>body</p>
</dd>
<dt>name</dt>
<dd>
<p>body
other</p>
</dd>
<dt>name</dt>
<dd>
<p>body</p>
<p>other</p>
</dd>
<dt>name</dt>
<dd>
<p>body</p>
<p>other</p>
</dd>
</dl>
</blockquote>
.
9 changes: 8 additions & 1 deletion tests/test_field_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ def test_plugin_parse(data_regression):
data_regression.check([t.as_dict() for t in tokens])


@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH))
fixtures = read_fixture_file(FIXTURE_PATH)


@pytest.mark.parametrize(
"line,title,input,expected",
fixtures,
ids=[f"{f[0]}-{f[1].replace(' ', '_')}" for f in fixtures],
)
def test_all(line, title, input, expected):
md = MarkdownIt("commonmark").use(fieldlist_plugin)
md.options["xhtmlOut"] = False
Expand Down