diff --git a/docs/changelog.md b/docs/changelog.md index f3335f1e..a224ad14 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -41,6 +41,11 @@ See the [Contributing Guide](contributing.md) for details. provided, rather than rendering them as `key="key"`, so the same source renders consistently under both the `html` and `xhtml` output formats (#1501). +* Stop list nesting short of the interpreter's recursion limit, as blockquote + nesting already is, instead of raising `RecursionError` on a deeply nested + list. The markers which no longer nest are kept as text (#1679). +* Fix quadratic-time splitting of a list block whose items span many lines, + which dominated the time taken to render a deeply nested list. ## [3.11.2] - 2026-10-19 diff --git a/markdown/blockprocessors.py b/markdown/blockprocessors.py index d3574045..34ce25a3 100644 --- a/markdown/blockprocessors.py +++ b/markdown/blockprocessors.py @@ -172,8 +172,12 @@ def __init__(self, *args): self.INDENT_RE = re.compile(r'^(([ ]{%s})+)' % self.tab_length) def test(self, parent: etree.Element, block: str) -> bool: + # A list calls `parseBlocks` once per level of nesting, so the + # nesting has to stop short of the interpreter's limit. The markers which no + # longer nest are kept as text, as they are for a blockquote. return block.startswith(' '*self.tab_length) and \ not self.parser.state.isstate('detabbed') and \ + not util.nearing_recursion_limit() and \ (parent.tag in self.ITEM_TYPES or (len(parent) and parent[-1] is not None and (parent[-1].tag in self.LIST_TYPES))) @@ -354,7 +358,9 @@ def __init__(self, parser: BlockParser): (self.tab_length, self.tab_length * 2 - 1)) def test(self, parent: etree.Element, block: str) -> bool: - return bool(self.RE.match(block)) + # As in `ListIndentProcessor`, nesting stops short of the recursion limit. + # `UListProcessor` inherits this method, so unordered lists are covered too. + return bool(self.RE.match(block)) and not util.nearing_recursion_limit() def run(self, parent: etree.Element, blocks: list[str]) -> None: # Check for multiple items in one block. @@ -417,7 +423,10 @@ def run(self, parent: etree.Element, blocks: list[str]) -> None: def get_items(self, block: str) -> list[str]: """ Break a block into list items. """ - items = [] + # The lines of an item are collected in a list and joined once at the end. + # Appending per line, as in `item = '{}\n{}'.format(item, line)`, copies the + # whole item every time, which is quadratic in the number of lines of an item. + items: list[list[str]] = [] for line in block.split('\n'): m = self.CHILD_RE.match(line) if m: @@ -428,18 +437,18 @@ def get_items(self, block: str) -> list[str]: INTEGER_RE = re.compile(r'(\d+)') self.STARTSWITH = INTEGER_RE.match(m.group(1)).group() # Append to the list - items.append(m.group(3)) + items.append([m.group(3)]) elif self.INDENT_RE.match(line): # This is an indented (possibly nested) item. - if items[-1].startswith(' '*self.tab_length): + if items[-1][0].startswith(' '*self.tab_length): # Previous item was indented. Append to that item. - items[-1] = '{}\n{}'.format(items[-1], line) + items[-1].append(line) else: - items.append(line) + items.append([line]) else: # This is another line of previous item. Append to that item. - items[-1] = '{}\n{}'.format(items[-1], line) - return items + items[-1].append(line) + return ['\n'.join(item) for item in items] class UListProcessor(OListProcessor): diff --git a/tests/test_syntax/blocks/test_list_nesting.py b/tests/test_syntax/blocks/test_list_nesting.py new file mode 100644 index 00000000..fc9bfe57 --- /dev/null +++ b/tests/test_syntax/blocks/test_list_nesting.py @@ -0,0 +1,82 @@ +""" +Python Markdown + +A Python implementation of John Gruber's Markdown. + +Documentation: https://python-markdown.github.io/ +GitHub: https://github.com/Python-Markdown/markdown/ +PyPI: https://pypi.org/project/Markdown/ + +Started by Manfred Stienstra (http://www.dwerg.net/). +Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +Currently maintained by Waylan Limberg (https://github.com/waylan), +Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +Copyright 2004 Manfred Stienstra (the original version) + +License: BSD (see LICENSE.md for details). +""" + +import markdown +from markdown.test_tools import TestCase, recursionlimit + + +class TestListNestingLimit(TestCase): + + # A list calls `parseBlocks` once per level of nesting, so the nesting + # has to stop short of the interpreter's limit. The remaining markers are kept as + # text, the same way a blockquote keeps its markers (see `TestBlockquoteBlocks`). + + def test_nesting_stops_short_of_the_recursion_limit(self): + # Ten levels of markers is more than the limit below allows to nest. + with recursionlimit(120): + result = markdown.markdown('- ' * 10 + 'x') + + self.assertLess(result.count('