Skip to content

Fix quadratic time for unclosed parentheses in link targets - #1680

Open
ParamTanna wants to merge 2 commits into
Python-Markdown:masterfrom
ParamTanna:fix-getlink-quadratic
Open

ParamTanna wants to merge 2 commits into
Python-Markdown:masterfrom
ParamTanna:fix-getlink-quadratic

Conversation

@ParamTanna

Copy link
Copy Markdown

Description

LinkInlineProcessor.getLink scans forward from the ( of a link target to pair the brackets. The treeprocessor calls it for every [text]( in the text, and when the parentheses never close each call scans to the end, so conversion time grows with the square of the number of unclosed (. Timings for markdown.markdown(s * n) on CPython 3.12:

'[a]('    x4000  (16000 bytes): 2.27s -> 0.015s
'[a](x '  x4000  (24000 bytes): 3.56s -> 0.018s
'[a](<u>' x4000  (28000 bytes): 4.11s -> 0.064s

This is the getLink companion to #1664 (which fixed the same problem in getText and noted that getLink was left out because its quote/title handling is more involved).

The quadratic inputs all turn out to be the ones with no quote before the matching ) — there the scan is just a plain bracket match. Those are now resolved from a table of closing positions built in a single pass per text (_findParenEnds, same monotonic-stack idea as #1664's getText), guarded by a per-text "next quote" table so the fast path only fires when no quote can start a title. Inputs where a quote may begin a title fall back to the original character-by-character loop unchanged; I measured those and they were already linear, so nothing is lost.

To be sure the fast path is behaviour-preserving, I compared the new getLink against the old loop on the raw (href, title, index, handled) tuple at every index of:

  • all 1,111,111 strings up to length 6 over the ten characters [ ] ( ) ' " < > a and space, and
  • 2,000,000 random strings of length 1–60 over a wider alphabet.

The results were identical in every case. The existing test suite passes unchanged (1166 tests), plus two new regression tests. test_many_unclosed_link_parens takes several seconds without the fix and finishes instantly with it.

AI Assistance Disclosure

  • No AI tools were used in preparing this PR.

  • AI tools were used and I have disclosed which ones, and have fully reviewed and verified their output.

    AI tools were used to assist with the following:

    • Code
    • Code Comments
    • Documentation
    • Changelog Entry
    • Commit Message
    • The Description Above
    • Other: the regression tests, the equivalence/fuzz comparison harness, and the timing script used to find the slow inputs

    Tool used: Claude Code (Claude Opus 4.8). I reviewed and verified all of it before submitting, including running the equivalence comparison and the full test suite myself.

Checklist

@waylan waylan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In addition to the noted issue below there is a merge conflict with the changelog. I am requesting @facelessuser do a final review of this.

Comment thread markdown/inlinepatterns.py Resolved
@waylan
waylan requested a review from facelessuser November 9, 2026 19:21
`LinkInlineProcessor.getLink` scans forward from the `(` of a link target
to pair the brackets. The treeprocessor calls it for every `[text](` in
the text, and when the parentheses never close each call scans to the end,
so conversion time grows with the square of the number of unclosed `(`.
For example `markdown.markdown('[a](' * n)` on CPython 3.12:

    '[a]('    x4000  (16000 bytes): 2.27s -> 0.015s
    '[a](x '  x4000  (24000 bytes): 3.56s -> 0.018s
    '[a](<u>' x4000  (28000 bytes): 4.11s -> 0.064s

When no quote appears before the matching `)` the scan is a plain bracket
match, so resolve it from a table of closing positions built in a single
pass per text and only fall back to the character-by-character loop when a
quote may start a title (the quote/title inputs were already linear). The
new `getLink` was compared with the old loop at every index of all 1.1M
strings up to length 6 over the 10 characters `[ ] ( ) ' " < > a` and
space, and of 2M longer random strings; the returned tuples were identical
in every case.

This is the `getLink` companion to the `getText` fix in Python-Markdown#1664.
@ParamTanna
ParamTanna force-pushed the fix-getlink-quadratic branch from 66ae093 to e0ed35b Compare November 10, 2026 15:21
@ParamTanna

Copy link
Copy Markdown
Author

Thanks. Both done:

  • Spelled out the abbreviation in the comment (paren is now parenthesis) so aspell stops flagging it.
  • Rebased onto current master and resolved the changelog conflict. Since 3.11.3 was cut in the meantime, the entry now sits at the top of the Unreleased ### Fixed list rather than next to the entries that moved into the release.

The link tests still pass. Ready for another look when you have time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review Needs to be reviewed and/or approved.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants