Repository: ipython/ipython

Issue:
`auto_match` slow and hogs CPU in some cases
To reproduce in IPython 8.2, paste `function_with_long_nameeee('arg`, and add the closing quotes at the end.
The slowness increases when the prefix is longer (in this case dominated by the function name `function_with_long_nameeee`), so if you don't notice it, try increasing it).

## Steps to reproduce with docker

1. Build the image and start a container:

```sh
build_oci_img() {
  docker build "$@" - <<EOF
  FROM python:3.8-slim
  RUN pip install ipython==8.2
EOF
}

build_oci_img
docker run --rm --interactive --tty "$(build_oci_img -q)" ipython -i -c 'get_ipython().auto_match = True'

```

2. Paste `function_with_long_nameeee('arg` and then type another `'` (single quote)

Comments:

Patch:
diff --git a/IPython/core/history.py b/IPython/core/history.py
index 9b0b2cbd0..1a89060e9 100644
--- a/IPython/core/history.py
+++ b/IPython/core/history.py
@@ -202,7 +202,6 @@ def __init__(self, profile="default", hist_file="", **traits):
         config : :class:`~traitlets.config.loader.Config`
             Config object. hist_file can also be set through this.
         """
-        # We need a pointer back to the shell for various tasks.
         super(HistoryAccessor, self).__init__(**traits)
         # defer setting hist_file from kwarg until after init,
         # otherwise the default kwarg value would clobber any value
@@ -344,11 +343,6 @@ def get_last_session_id(self):
     def get_tail(self, n=10, raw=True, output=False, include_latest=False):
         """Get the last n lines from the history database.
 
-        Most recent entry last.
-
-        Completion will be reordered so that that the last ones are when
-        possible from current session.
-
         Parameters
         ----------
         n : int
@@ -367,31 +361,12 @@ def get_tail(self, n=10, raw=True, output=False, include_latest=False):
         self.writeout_cache()
         if not include_latest:
             n += 1
-        # cursor/line/entry
-        this_cur = list(
-            self._run_sql(
-                "WHERE session == ? ORDER BY line DESC LIMIT ?  ",
-                (self.session_number, n),
-                raw=raw,
-                output=output,
-            )
-        )
-        other_cur = list(
-            self._run_sql(
-                "WHERE session != ? ORDER BY session DESC, line DESC LIMIT ?",
-                (self.session_number, n),
-                raw=raw,
-                output=output,
-            )
+        cur = self._run_sql(
+            "ORDER BY session DESC, line DESC LIMIT ?", (n,), raw=raw, output=output
         )
-
-        everything = this_cur + other_cur
-
-        everything = everything[:n]
-
         if not include_latest:
-            return list(everything)[:0:-1]
-        return list(everything)[::-1]
+            return reversed(list(cur)[1:])
+        return reversed(list(cur))
 
     @catch_corrupt_db
     def search(self, pattern="*", raw=True, search_raw=True,
@@ -560,7 +535,6 @@ def _dir_hist_default(self):
     def __init__(self, shell=None, config=None, **traits):
         """Create a new history manager associated with a shell instance.
         """
-        # We need a pointer back to the shell for various tasks.
         super(HistoryManager, self).__init__(shell=shell, config=config,
             **traits)
         self.save_flag = threading.Event()
@@ -656,6 +630,59 @@ def get_session_info(self, session=0):
 
         return super(HistoryManager, self).get_session_info(session=session)
 
+    @catch_corrupt_db
+    def get_tail(self, n=10, raw=True, output=False, include_latest=False):
+        """Get the last n lines from the history database.
+
+        Most recent entry last.
+
+        Completion will be reordered so that that the last ones are when
+        possible from current session.
+
+        Parameters
+        ----------
+        n : int
+            The number of lines to get
+        raw, output : bool
+            See :meth:`get_range`
+        include_latest : bool
+            If False (default), n+1 lines are fetched, and the latest one
+            is discarded. This is intended to be used where the function
+            is called by a user command, which it should not return.
+
+        Returns
+        -------
+        Tuples as :meth:`get_range`
+        """
+        self.writeout_cache()
+        if not include_latest:
+            n += 1
+        # cursor/line/entry
+        this_cur = list(
+            self._run_sql(
+                "WHERE session == ? ORDER BY line DESC LIMIT ?  ",
+                (self.session_number, n),
+                raw=raw,
+                output=output,
+            )
+        )
+        other_cur = list(
+            self._run_sql(
+                "WHERE session != ? ORDER BY session DESC, line DESC LIMIT ?",
+                (self.session_number, n),
+                raw=raw,
+                output=output,
+            )
+        )
+
+        everything = this_cur + other_cur
+
+        everything = everything[:n]
+
+        if not include_latest:
+            return list(everything)[:0:-1]
+        return list(everything)[::-1]
+
     def _get_range_session(self, start=1, stop=None, raw=True, output=False):
         """Get input and output history from the current session. Called by
         get_range, and takes similar parameters."""
diff --git a/IPython/terminal/shortcuts.py b/IPython/terminal/shortcuts.py
index 6c1ba0418..a68be9a6f 100644
--- a/IPython/terminal/shortcuts.py
+++ b/IPython/terminal/shortcuts.py
@@ -140,6 +140,18 @@ def _following_text():
         _following_text_cache[pattern] = condition
         return condition
 
+    @Condition
+    def not_inside_unclosed_string():
+        app = get_app()
+        s = app.current_buffer.document.text_before_cursor
+        # remove escaped quotes
+        s = s.replace('\\"', "").replace("\\'", "")
+        # remove triple-quoted string literals
+        s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
+        # remove single-quoted string literals
+        s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
+        return not ('"' in s or "'" in s)
+
     # auto match
     @kb.add("(", filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))
     def _(event):
@@ -160,7 +172,7 @@ def _(event):
         '"',
         filter=focused_insert
         & auto_match
-        & preceding_text(r'^([^"]+|"[^"]*")*$')
+        & not_inside_unclosed_string
         & following_text(r"[,)}\]]|$"),
     )
     def _(event):
@@ -171,13 +183,35 @@ def _(event):
         "'",
         filter=focused_insert
         & auto_match
-        & preceding_text(r"^([^']+|'[^']*')*$")
+        & not_inside_unclosed_string
         & following_text(r"[,)}\]]|$"),
     )
     def _(event):
         event.current_buffer.insert_text("''")
         event.current_buffer.cursor_left()
 
+    @kb.add(
+        '"',
+        filter=focused_insert
+        & auto_match
+        & not_inside_unclosed_string
+        & preceding_text(r'^.*""$'),
+    )
+    def _(event):
+        event.current_buffer.insert_text('""""')
+        event.current_buffer.cursor_left(3)
+
+    @kb.add(
+        "'",
+        filter=focused_insert
+        & auto_match
+        & not_inside_unclosed_string
+        & preceding_text(r"^.*''$"),
+    )
+    def _(event):
+        event.current_buffer.insert_text("''''")
+        event.current_buffer.cursor_left(3)
+
     # raw string
     @kb.add(
         "(", filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$")


Test patch:
diff --git a/IPython/core/tests/test_displayhook.py b/IPython/core/tests/test_displayhook.py
index 6ad897934..22899f3dd 100644
--- a/IPython/core/tests/test_displayhook.py
+++ b/IPython/core/tests/test_displayhook.py
@@ -28,7 +28,7 @@ def test_output_quiet():
     with AssertNotPrints('2'):
         ip.run_cell('1+1;\n#commented_out_function()', store_history=True)
 
-def test_underscore_no_overrite_user():
+def test_underscore_no_overwrite_user():
     ip.run_cell('_ = 42', store_history=True)
     ip.run_cell('1+1', store_history=True)
 
@@ -41,7 +41,7 @@ def test_underscore_no_overrite_user():
         ip.run_cell('_', store_history=True)
 
 
-def test_underscore_no_overrite_builtins():
+def test_underscore_no_overwrite_builtins():
     ip.run_cell("import gettext ; gettext.install('foo')", store_history=True)
     ip.run_cell('3+3', store_history=True)
 
diff --git a/IPython/core/tests/test_history.py b/IPython/core/tests/test_history.py
index 73d50c87d..a9ebafddd 100644
--- a/IPython/core/tests/test_history.py
+++ b/IPython/core/tests/test_history.py
@@ -17,7 +17,7 @@
 # our own packages
 from traitlets.config.loader import Config
 
-from IPython.core.history import HistoryManager, extract_hist_ranges
+from IPython.core.history import HistoryAccessor, HistoryManager, extract_hist_ranges
 
 
 def test_proper_default_encoding():
@@ -227,3 +227,81 @@ def test_histmanager_disabled():
 
     # hist_file should not be created
     assert hist_file.exists() is False
+
+
+def test_get_tail_session_awareness():
+    """Test .get_tail() is:
+        - session specific in HistoryManager
+        - session agnostic in HistoryAccessor
+    same for .get_last_session_id()
+    """
+    ip = get_ipython()
+    with TemporaryDirectory() as tmpdir:
+        tmp_path = Path(tmpdir)
+        hist_file = tmp_path / "history.sqlite"
+        get_source = lambda x: x[2]
+        hm1 = None
+        hm2 = None
+        ha = None
+        try:
+            # hm1 creates a new session and adds history entries,
+            # ha catches up
+            hm1 = HistoryManager(shell=ip, hist_file=hist_file)
+            hm1_last_sid = hm1.get_last_session_id
+            ha = HistoryAccessor(hist_file=hist_file)
+            ha_last_sid = ha.get_last_session_id
+
+            hist1 = ["a=1", "b=1", "c=1"]
+            for i, h in enumerate(hist1 + [""], start=1):
+                hm1.store_inputs(i, h)
+            assert list(map(get_source, hm1.get_tail())) == hist1
+            assert list(map(get_source, ha.get_tail())) == hist1
+            sid1 = hm1_last_sid()
+            assert sid1 is not None
+            assert ha_last_sid() == sid1
+
+            # hm2 creates a new session and adds entries,
+            # ha catches up
+            hm2 = HistoryManager(shell=ip, hist_file=hist_file)
+            hm2_last_sid = hm2.get_last_session_id
+
+            hist2 = ["a=2", "b=2", "c=2"]
+            for i, h in enumerate(hist2 + [""], start=1):
+                hm2.store_inputs(i, h)
+            tail = hm2.get_tail(n=3)
+            assert list(map(get_source, tail)) == hist2
+            tail = ha.get_tail(n=3)
+            assert list(map(get_source, tail)) == hist2
+            sid2 = hm2_last_sid()
+            assert sid2 is not None
+            assert ha_last_sid() == sid2
+            assert sid2 != sid1
+
+            # but hm1 still maintains its point of reference
+            # and adding more entries to it doesn't change others
+            # immediate perspective
+            assert hm1_last_sid() == sid1
+            tail = hm1.get_tail(n=3)
+            assert list(map(get_source, tail)) == hist1
+
+            hist3 = ["a=3", "b=3", "c=3"]
+            for i, h in enumerate(hist3 + [""], start=5):
+                hm1.store_inputs(i, h)
+            tail = hm1.get_tail(n=7)
+            assert list(map(get_source, tail)) == hist1 + [""] + hist3
+            tail = hm2.get_tail(n=3)
+            assert list(map(get_source, tail)) == hist2
+            tail = ha.get_tail(n=3)
+            assert list(map(get_source, tail)) == hist2
+            assert hm1_last_sid() == sid1
+            assert hm2_last_sid() == sid2
+            assert ha_last_sid() == sid2
+        finally:
+            if hm1:
+                hm1.save_thread.stop()
+                hm1.db.close()
+            if hm2:
+                hm2.save_thread.stop()
+                hm2.db.close()
+            if ha:
+                ha.db.close()