From 7d9f3f40b89ff98c956e681464ba906ea14650c0 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Thu, 27 Aug 2026 19:24:43 -0400 Subject: [PATCH] Replace auto-linked URLs in a single pass Both halves of the link replacement in _transform rebuilt the whole string once per URL: the search loop spliced a token in with a slice concatenation for every match, and the restore step ran a separate str.replace for every token. A post with many links therefore cost time quadratic in its length. Use re.sub for both, so each is one linear pass. Output is unchanged. On 448 KB of text made of 32,000 short links, render_html goes from 22.2s to 0.071s, and timings now double with the input rather than quadrupling. Signed-off-by: Arpit Jain --- src/bbcode/__init__.py | 32 ++++++++++++++++---------------- tests.py | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/bbcode/__init__.py b/src/bbcode/__init__.py index 7123210..d70275c 100644 --- a/src/bbcode/__init__.py +++ b/src/bbcode/__init__.py @@ -27,6 +27,10 @@ r"(?:com|net|org|edu|biz|gov|mil|info|io|name|me|tv|us|uk|mobi))" ) +# Matches the placeholder tokens used to hold links aside while escaping and cosmetic +# replacement happen, so they can all be substituted back in a single pass. +_link_token_re = re.compile(r"\{\{ bbcode-link-\d+ \}\}") + # Taken from python-requests class CaseInsensitiveDict(MutableMapping): @@ -634,29 +638,25 @@ def _transform( if self.replace_links and replace_links: # If we're replacing links in the text (i.e. not those in [url] tags) then # we need to be careful to pull them out before doing any escaping or - # cosmetic replacement. - pos = 0 - while True: - match = _url_re.search(data, pos) - if not match: - break - # Replace any link with a token that we can substitute back in after - # replacements. + # cosmetic replacement. Each link is swapped for a token here and put back + # at the end. Both halves are single passes: doing it a match at a time + # rebuilt the whole string once per link, so a post with many links cost + # time quadratic in its length. + def _tokenize_link(match): token = "{{ bbcode-link-%s }}" % len(url_matches) url_matches[token] = self._link_replace(match, **context) - start, end = match.span() - data = data[:start] + token + data[end:] - # To be perfectly accurate, this should probably be - # len(data[:start] + token), but start will work, because the token - # itself won't match as a URL. - pos = start + return token + + data = _url_re.sub(_tokenize_link, data) if escape_html: data = self._replace(data, self.REPLACE_ESCAPE) if replace_cosmetic: data = self._replace(data, self.REPLACE_COSMETIC) # Now put the replaced links back in the text. - for token, replacement in url_matches.items(): - data = data.replace(token, replacement) + if url_matches: + data = _link_token_re.sub( + lambda match: url_matches.get(match.group(0), match.group(0)), data + ) if transform_newlines: data = data.replace("\n", "\r") return data diff --git a/tests.py b/tests.py index 5cc60dc..1464cae 100644 --- a/tests.py +++ b/tests.py @@ -321,6 +321,21 @@ def test_urls(self): num = len(bbcode._url_re.findall(line)) self.assertEqual(num, 1, 'Found %d links in "%s"' % (num, line.strip())) + def test_many_links(self): + # Every link in a post with a lot of them should still be replaced, and the + # placeholder tokens used while escaping should not leak into the output. + src = "http://a.co/x " * 500 + dst = bbcode.render_html(src) + self.assertEqual(dst.count('href="http://a.co/x"'), 500) + self.assertNotIn("bbcode-link", dst) + + def test_link_token_in_source(self): + # Text that happens to look like an internal placeholder is left alone when + # there is no link to substitute for it. + self.assertEqual( + bbcode.render_html("{{ bbcode-link-0 }}"), "{{ bbcode-link-0 }}" + ) + def test_unicode(self): src = "[center]ƒünk¥ • §tüƒƒ[/center]" dst = '
ƒünk¥ • §tüƒƒ
'