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
+
+
    +
  • 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

+
+
+. + 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