diff --git a/docs/changelog.md b/docs/changelog.md index cd6b79bb..ea02e6d3 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,11 @@ See the [Contributing Guide](contributing.md) for details. ## [Unreleased] +### Changed + +* Inline processors now resume searching after the previous match, improving + performance for repeated inline patterns (#1619). + ### Fixed * Fix an issue with excessive backtracking when matching inline code blocks (#1617). diff --git a/markdown/treeprocessors.py b/markdown/treeprocessors.py index 9a27446d..4d2f318d 100644 --- a/markdown/treeprocessors.py +++ b/markdown/treeprocessors.py @@ -323,12 +323,17 @@ def __applyPattern( placeholder = self.__stashNode(node, pattern.type()) if new_style: + # Return the index just past the inserted placeholder so the + # next call scans only the unprocessed tail. Scanning from 0 + # after every match makes repeated inline patterns quadratic. return "{}{}{}".format(data[:start], - placeholder, data[end:]), True, 0 + placeholder, data[end:]), True, start + len(placeholder) else: # pragma: no cover return "{}{}{}{}".format(leftData, match.group(1), - placeholder, match.groups()[-1]), True, 0 + placeholder, match.groups()[-1]), True, ( + len(leftData) + len(match.group(1)) + len(placeholder) + ) def __build_ancestors(self, parent: etree.Element | None, parents: list[str]) -> None: """Build the ancestor list.""" diff --git a/tests/test_apis.py b/tests/test_apis.py index 55e2cdb6..b4afc4f7 100644 --- a/tests/test_apis.py +++ b/tests/test_apis.py @@ -725,6 +725,15 @@ def testInlineProcessorDoesntCrashWithWrongAtomicString(self): '

a <b>atomic</b> c

' ) + def testInlineProcessorAdvancesSearchIndex(self): + """Test that repeated matches resume after the previous match.""" + pattern = _InlineProcessorThatRecordsSearchIndex(r'x', self.md) + self.md.inlinePatterns.register(pattern, 'record-search-index', 1000) + + self.assertEqual(self.md.convert('xxxx'), '

xxxx

') + self.assertEqual(pattern.start_indices[0], 0) + self.assertGreater(pattern.start_indices[1], pattern.start_indices[0]) + class _InlineProcessorThatReturnsAtomicString(inlinepatterns.InlineProcessor): """ Return a simple text of `group(1)` of a Pattern. """ @@ -732,6 +741,32 @@ def handleMatch(self, m, data): return markdown.util.AtomicString('atomic'), m.start(0), m.end(0) +class _RecordingPattern: + """Proxy a compiled pattern while recording the search offsets.""" + + def __init__(self, pattern, start_indices): + self.pattern = pattern + self.start_indices = start_indices + + def finditer(self, data, start_index=0): + self.start_indices.append(start_index) + return self.pattern.finditer(data, start_index) + + +class _InlineProcessorThatRecordsSearchIndex(inlinepatterns.InlineProcessor): + """Record each offset passed to the processor's compiled expression.""" + + def __init__(self, pattern, md): + super().__init__(pattern, md) + self.start_indices = [] + + def getCompiledRegExp(self): + return _RecordingPattern(self.compiled_re, self.start_indices) + + def handleMatch(self, m, data): + return m.group(0), m.start(0), m.end(0) + + class TestConfigParsing(unittest.TestCase): def assertParses(self, value, result): self.assertIs(markdown.util.parseBoolValue(value, False), result)