From c179c2a596e57d64940aa58160832a047600e474 2022-08-30 22:56:16
From: satoru <satorulogic@gmail.com>
Date: 2022-08-30 22:56:16
Subject: [PATCH] Fix #13654, improve performance of auto match for quotes

As pointed out in #13654, auto matching of quotes may take a long time
if the prefix is long.
To be more precise, the longer the text before the first quote, the
slower it is.
This is all caused by the regex pattern used: `r'^([^"]+|"[^"]*")*$'`,
which I suspect is O(2^N) slow.

```python
In [1]: text = "function_with_long_nameeee('arg"

In [2]: import re

In [3]: pattern = re.compile(r"^([^']+|'[^']*')*$")

In [4]: %timeit pattern.match(text)
10.3 s ± 67.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [5]: %timeit pattern.match("1'")
312 ns ± 0.775 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops
    each)

In [6]: %timeit pattern.match("12'")
462 ns ± 1.95 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops
    each)

In [7]: %timeit pattern.match("123'")
766 ns ± 6.32 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops
    each)

In [8]: %timeit pattern.match("1234'")
1.59 µs ± 20.9 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops
  each)
```

But the pattern we want here can actually be detected with a Python
implemention in O(N) time.

---

diff --git a/IPython/terminal/shortcuts.py b/IPython/terminal/shortcuts.py
index a68be9a..4b51b28 100644
--- a/IPython/terminal/shortcuts.py
+++ b/IPython/terminal/shortcuts.py
@@ -106,20 +106,37 @@ def create_ipython_shortcuts(shell):
     def auto_match():
         return shell.auto_match
 
+    def all_quotes_paired(quote, buf):
+        paired = True
+        i = 0
+        while i < len(buf):
+            c = buf[i]
+            if c == quote:
+                paired = not paired
+            elif c == '\\':
+                i += 1
+            i += 1
+        return paired
+
     focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER)
     _preceding_text_cache = {}
     _following_text_cache = {}
 
     def preceding_text(pattern):
-        try:
+        if pattern in _preceding_text_cache:
             return _preceding_text_cache[pattern]
-        except KeyError:
-            pass
-        m = re.compile(pattern)
 
-        def _preceding_text():
-            app = get_app()
-            return bool(m.match(app.current_buffer.document.current_line_before_cursor))
+        if callable(pattern):
+            def _preceding_text():
+                app = get_app()
+                before_cursor = app.current_buffer.document.current_line_before_cursor
+                return bool(pattern(before_cursor))
+        else:
+            m = re.compile(pattern)
+
+            def _preceding_text():
+                app = get_app()
+                return bool(m.match(app.current_buffer.document.current_line_before_cursor))
 
         condition = Condition(_preceding_text)
         _preceding_text_cache[pattern] = condition
@@ -173,6 +190,7 @@ def create_ipython_shortcuts(shell):
         filter=focused_insert
         & auto_match
         & not_inside_unclosed_string
+        & preceding_text(lambda line: all_quotes_paired('"', line))
         & following_text(r"[,)}\]]|$"),
     )
     def _(event):
@@ -184,6 +202,7 @@ def create_ipython_shortcuts(shell):
         filter=focused_insert
         & auto_match
         & not_inside_unclosed_string
+        & preceding_text(lambda line: all_quotes_paired("'", line))
         & following_text(r"[,)}\]]|$"),
     )
     def _(event):