From f4f0a0e3c65343efeedff04198861524f0e5af32 Mon Sep 17 00:00:00 2001
From: Chris Sewell
Date: Sat, 18 Feb 2023 11:33:04 +0100
Subject: [PATCH] =?UTF-8?q?=F0=9F=91=8C=20Improve=20field=20lists=20(#65)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
```
---
mdit_py_plugins/field_list/__init__.py | 114 +++++++++++++++++------
tests/fixtures/field_list.md | 119 ++++++++++++++++++++++++-
tests/test_field_list.py | 9 +-
3 files changed, 209 insertions(+), 33 deletions(-)
diff --git a/mdit_py_plugins/field_list/__init__.py b/mdit_py_plugins/field_list/__init__.py
index 48ecca4..6a4e82e 100644
--- a/mdit_py_plugins/field_list/__init__.py
+++ b/mdit_py_plugins/field_list/__init__.py
@@ -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
@@ -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",
@@ -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])
@@ -138,9 +141,11 @@ 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
@@ -148,34 +153,69 @@ def _fieldlist_rule(state: StateBlock, startLine: int, endLine: int, silent: boo
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
@@ -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
diff --git a/tests/fixtures/field_list.md b/tests/fixtures/field_list.md
index bf31ad9..73b633c 100644
--- a/tests/fixtures/field_list.md
+++ b/tests/fixtures/field_list.md
@@ -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
+.
+
+- Date
+-
+
2001-08-16
+
+- Version
+-
+
1
+
+- Authors
+-
+
+
+- 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
+
+
+.
+
Body alignment:
.
:no body:
@@ -11,6 +55,12 @@ Body alignment:
paragraph 3
+:body less: paragraph 1
+
+ paragraph 2
+
+ paragraph 3
+
:body on 2nd line:
paragraph 1
@@ -40,6 +90,12 @@ running onto new line
paragraph 2
paragraph 3
+body less
+
+paragraph 1
+paragraph 2
+paragraph 3
+
body on 2nd line
paragraph 1
@@ -53,6 +109,24 @@ running onto new line
.
+choose smallest indent
+.
+:name: a
+
+ b
+
+ c
+.
+
+- name
+-
+
a
+b
+c
+
+
+.
+
Empty name:
.
::
@@ -118,7 +192,7 @@ Body list:
Body code block
.
:name:
- code
+ not code
:name: body
code
@@ -126,8 +200,7 @@ Body code block
- name
-
-
code
-
+not code
- name
-
@@ -190,6 +263,13 @@ Following blocks:
```python
code
```
+:name: body
+ more
+
+ more
+trailing
+
+other
.
- name
@@ -217,6 +297,16 @@ code
code
+
+- name
+-
+
body
+more
+more
+trailing
+
+
+other
.
In list:
@@ -240,6 +330,14 @@ In list:
In blockquote:
.
> :name: body
+> :name: body
+> other
+> :name: body
+>
+> other
+> :name: body
+>
+> other
.
@@ -247,6 +345,21 @@ In blockquote:
-
body
+- name
+-
+
body
+other
+
+- name
+-
+
body
+other
+
+- name
+-
+
body
+other
+
.
diff --git a/tests/test_field_list.py b/tests/test_field_list.py
index cf00ae9..fa6f49b 100644
--- a/tests/test_field_list.py
+++ b/tests/test_field_list.py
@@ -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