Skip to content

Commit 86d1e46

Browse files
authored
Fix quadratic processing time in fenced_code preprocessor (up to ~10x faster)
1 parent 6f69248 commit 86d1e46

3 files changed

Lines changed: 80 additions & 3 deletions

File tree

‎docs/changelog.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ See the [Contributing Guide](contributing.md) for details.
2222
with the closer, dropping text.
2323
* Fix the unused part of a partially consumed closing delimiter run being classified using the replacement
2424
placeholder instead of the text that preceded it.
25+
* Fix quadratic processing time in the `fenced_code` extension for documents
26+
with many fenced code blocks (#1660).
2527

2628
## [3.11.3] - 2026-11-05
2729

‎markdown/extensions/fenced_code.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ def run(self, lines: list[str]) -> list[str]:
102102
self.checked_for_deps = True
103103

104104
text = "\n".join(lines)
105+
# Collect the output in pieces, as rebuilding the full text after each block is quadratic.
106+
pieces: list[str] = []
107+
last = 0
105108
index = 0
106109
while 1:
107110
m = self.FENCED_BLOCK_RE.search(text, index)
@@ -164,12 +167,13 @@ def run(self, lines: list[str]) -> list[str]:
164167
code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>'
165168

166169
placeholder = self.md.htmlStash.store(code)
167-
text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}'
170+
pieces.append(f'{text[last:m.start()]}\n{placeholder}\n')
168171
# Continue from after the replaced text in the next iteration.
169-
index = m.start() + 1 + len(placeholder)
172+
index = last = m.end()
170173
else:
171174
break
172-
return text.split("\n")
175+
pieces.append(text[last:])
176+
return "".join(pieces).split("\n")
173177

174178
def handle_attrs(
175179
self, attrs: Iterable[tuple[str, str | None]]

‎tests/test_syntax/extensions/test_fenced_code.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,77 @@ def testFencedCodeMismatchedCurlyInAttrs(self):
455455
extensions=['fenced_code', 'attr_list']
456456
)
457457

458+
def testMultipleFencedBlocks(self):
459+
self.assertMarkdownRenders(
460+
self.dedent(
461+
'''
462+
Text before.
463+
464+
```python
465+
first = 1
466+
```
467+
468+
Text between.
469+
470+
~~~
471+
second
472+
~~~
473+
474+
Text after.
475+
'''
476+
),
477+
self.dedent(
478+
'''
479+
<p>Text before.</p>
480+
<pre><code class="language-python">first = 1
481+
</code></pre>
482+
<p>Text between.</p>
483+
<pre><code>second
484+
</code></pre>
485+
<p>Text after.</p>
486+
'''
487+
),
488+
extensions=['fenced_code']
489+
)
490+
491+
def testAdjacentFencedBlocks(self):
492+
# The second block opens on the line right after the first one closes.
493+
self.assertMarkdownRenders(
494+
self.dedent(
495+
'''
496+
```
497+
first
498+
```
499+
~~~
500+
second
501+
~~~
502+
'''
503+
),
504+
self.dedent(
505+
'''
506+
<pre><code>first
507+
</code></pre>
508+
<pre><code>second
509+
</code></pre>
510+
'''
511+
),
512+
extensions=['fenced_code']
513+
)
514+
515+
def testFencedBlockAtEndWithoutNewline(self):
516+
self.assertMarkdownRenders(
517+
'Text.\n\n```\ncode\n```',
518+
'<p>Text.</p>\n<pre><code>code\n</code></pre>',
519+
extensions=['fenced_code']
520+
)
521+
522+
def testManyFencedBlocks(self):
523+
self.assertMarkdownRenders(
524+
'\n\n'.join(f'Paragraph {i}.\n\n```\nblock {i}\n```' for i in range(50)),
525+
'\n'.join(f'<p>Paragraph {i}.</p>\n<pre><code>block {i}\n</code></pre>' for i in range(50)),
526+
extensions=['fenced_code']
527+
)
528+
458529

459530
class TestFencedCodeWithCodehilite(TestCase):
460531

0 commit comments

Comments
 (0)