Skip to content

Commit

Permalink
👌 Improve field lists (#65)
Browse files Browse the repository at this point in the history
This introduces better handling of the field list body content, so that it can be dynamically indented (the same as in rST).

Before, one could only indent like:

```restructuredtext
:name1: first line
              
        all other lines must be aligned with it

:name2:
  
  no first line so 2 space indent
```

But now, the indentation will be taken as the minimum of all content, e.g.

```restructuredtext
:name1: first line

 this is indented 1, so all content will follow this
```
  • Loading branch information
chrisjsewell authored Feb 18, 2023
1 parent 9e57524 commit f4f0a0e
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 33 deletions.
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

0 comments on commit f4f0a0e

Please sign in to comment.