Repository: spyder-ide/spyder

Issue:
autocomplete fails when pressing tab too quickly
<!--- **PLEASE READ:** When submitting here, please ensure you've completed the following checklist and checked the boxes to confirm. Issue reports without it may be closed. Thanks! --->

### Issue Report Checklist

* [x] Searched the [issues page](https://github.com/spyder-ide/spyder/issues?q=is%3Aissue) for similar reports
* [x] Read the relevant sections of the [Spyder Troubleshooting Guide](https://github.com/spyder-ide/spyder/wiki/Troubleshooting-Guide-and-FAQ) and followed its advice
* [ ] Reproduced the issue after updating with ``conda update spyder`` (or ``pip``, if not using Anaconda)
* [ ] Could not reproduce inside ``jupyter qtconsole`` (if console-related)
* [x] Tried basic troubleshooting (if a bug/error)
    * [x] Restarted Spyder
    * [ ] Reset preferences with ``spyder --reset``
    * [ ] Reinstalled the latest version of [Anaconda](https://www.anaconda.com/download/)
    * [ ] Tried the other applicable steps from the Troubleshooting Guide
* [ ] Completed the **Problem Description**, **Steps to Reproduce** and **Version** sections below


## Problem Description
autocomplete fails when pressing tab too quickly


### What steps reproduce the problem?

1. in the editor, type 
```
import numpy as np
np.new
```
2. `<tab><enter>`

### What is the expected output? What do you see instead?
if `<tab><enter>` pressed quickly:
```
np.new
newaxis
```
if more slowly:
`np.newaxis`



## Versions
<!--- You can get this information from Help > About Spyder...
or (if Spyder won't launch) the "conda list" command
from the Anaconda Prompt/Terminal/command line. --->

* Spyder version: Spyder 4.0.0.dev0 (last master)
Python 3.7.0 64-bit | Qt 5.9.3 | PyQt5 5.9.2 | Darwin 18.2.0 



Discussion:

Resolving patch:
diff --git a/spyder/plugins/editor/widgets/base.py b/spyder/plugins/editor/widgets/base.py
--- a/spyder/plugins/editor/widgets/base.py
+++ b/spyder/plugins/editor/widgets/base.py
@@ -58,14 +58,21 @@ def setup_appearance(self, size, font):
         self.resize(*size)
         self.setFont(font)
 
-    def show_list(self, completion_list):
+    def show_list(self, completion_list, position):
         # Completions are handled differently for the Internal
         # console.
+        if position is None:
+            # Somehow the position was not saved.
+            # Hope that the current position is still valid
+            self.position = self.textedit.textCursor().position()
+        elif self.textedit.textCursor().position() < position:
+            # hide the text as we moved away from the position
+            return
+        else:
+            self.position = position
+
         if not isinstance(completion_list[0], dict):
             self.is_internal_console = True
-
-        if not self.is_internal_console:
-            self.textedit.completion_text = ''
         self.completion_list = completion_list
         self.clear()
 
@@ -145,7 +152,8 @@ def show_list(self, completion_list):
             for completion in completion_list:
                 completion['point'] = tooltip_point
 
-        if to_text_string(self.textedit.completion_text):
+        if to_text_string(to_text_string(
+                self.textedit.get_current_word(completion=True))):
             # When initialized, if completion text is not empty, we need
             # to update the displayed list:
             self.update_current()
@@ -183,8 +191,16 @@ def keyPressEvent(self, event):
             else:
                 QListWidget.keyPressEvent(self, event)
         elif len(text) or key == Qt.Key_Backspace:
-            self.textedit.keyPressEvent(event)
-            self.update_current()
+            # If the cursor goes behind the current position,
+            # the autocomplete is no longer relevant
+            if self.textedit.textCursor().position() < self.position or (
+                    key == Qt.Key_Backspace and (
+                    self.textedit.textCursor().position() <= self.position)):
+                self.hide()
+                self.textedit.keyPressEvent(event)
+            else:
+                self.textedit.keyPressEvent(event)
+                self.update_current()
         elif modifier:
             self.textedit.keyPressEvent(event)
         else:
@@ -192,7 +208,8 @@ def keyPressEvent(self, event):
             QListWidget.keyPressEvent(self, event)
 
     def update_current(self):
-        completion_text = to_text_string(self.textedit.completion_text)
+        completion_text = to_text_string(
+                self.textedit.get_current_word(completion=True))
         if completion_text:
             for row, completion in enumerate(self.completion_list):
                 if not self.is_internal_console:
@@ -226,7 +243,8 @@ def focusOutEvent(self, event):
     def item_selected(self, item=None):
         if item is None:
             item = self.currentItem()
-        self.textedit.insert_completion(to_text_string(item.text()))
+        self.textedit.insert_completion(to_text_string(item.text()),
+                                        self.position)
         self.hide()
 
     @Slot(int)
@@ -280,7 +298,6 @@ def __init__(self, parent=None):
         self.codecompletion_auto = False
         self.codecompletion_case = True
         self.codecompletion_enter = False
-        self.completion_text = ""
         self.setup_completion()
 
         self.calltip_widget = CallTipWidget(self, hide_timer_on=False)
@@ -1079,21 +1096,24 @@ def select_completion_list(self):
         """Completion list is active, Enter was just pressed"""
         self.completion_widget.item_selected()
 
-    def insert_completion(self, text):
+    def insert_completion(self, text, position):
         if text:
-            word = self.get_current_word(completion=True) or ""
-            common_prefix = osp.commonprefix([text, word])
-            start = len(common_prefix) if common_prefix is not None else 0
-            if start == len(word):
-                text = text[start:]
-            else:
-                # Remove current word since the completion is not
-                # a perfect match
+            # Set position to where the request was made
+            if position is not None:
+                cursor = self.textCursor()
+                cursor.setPosition(position)
+                self.setTextCursor(cursor)
+            # Move to the beginning of the selected word
+            result = self.get_current_word_and_position(completion=True)
+            if result is not None:
+                position = result[1]
                 cursor = self.textCursor()
-                cursor.movePosition(QTextCursor.PreviousCharacter,
-                                    QTextCursor.KeepAnchor,
-                                    len(word))
+                cursor.setPosition(position)
+                # Remove the word under the cursor
+                cursor.select(QTextCursor.WordUnderCursor)
                 cursor.removeSelectedText()
+                self.setTextCursor(cursor)
+            # Add text
             self.insert_text(text)
             self.document_did_change()
 
diff --git a/spyder/plugins/editor/widgets/codeeditor.py b/spyder/plugins/editor/widgets/codeeditor.py
--- a/spyder/plugins/editor/widgets/codeeditor.py
+++ b/spyder/plugins/editor/widgets/codeeditor.py
@@ -473,6 +473,7 @@ def __init__(self, parent=None):
         self.range_formatting_enabled = False
         self.formatting_characters = []
         self.rename_support = False
+        self.last_completion_position = None
 
         # Editor Extensions
         self.editor_extensions = EditorExtensionsManager(self)
@@ -777,6 +778,7 @@ def do_completion(self, automatic=False):
             'line': line,
             'column': column
         }
+        self.last_completion_position = self.textCursor().position()
         return params
 
     @handles(LSPRequestTypes.DOCUMENT_COMPLETION)
@@ -784,7 +786,8 @@ def process_completion(self, params):
         if len(params['params']) > 0:
             completion_list = sorted(
                 params['params'], key=lambda x: x['sortText'])
-            self.completion_widget.show_list(completion_list)
+            position = self.last_completion_position
+            self.completion_widget.show_list(completion_list, position)
 
     # ------------- LSP: Signature Hints ------------------------------------
 
@@ -2791,8 +2794,6 @@ def keyPressEvent(self, event):
                     cursor.removeSelectedText()
                 else:
                     TextEditBaseWidget.keyPressEvent(self, event)
-                    if self.is_completion_widget_visible():
-                        self.completion_text = self.completion_text[:-1]
         # elif key == Qt.Key_Period:
         #     self.insert_text(text)
         #     if (self.is_python_like()) and not \
@@ -2856,8 +2857,6 @@ def keyPressEvent(self, event):
             event.accept()
         elif not event.isAccepted():
             TextEditBaseWidget.keyPressEvent(self, event)
-            if self.is_completion_widget_visible() and text:
-                self.completion_text += text
         if len(text) > 0:
             self.document_did_change(text)
             # self.do_completion(automatic=True)
diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py
--- a/spyder/widgets/mixins.py
+++ b/spyder/widgets/mixins.py
@@ -340,8 +340,9 @@ def remove_text(self, position_from, position_to):
         cursor = self.__select_text(position_from, position_to)
         cursor.removeSelectedText()
 
-    def get_current_word(self, completion=False):
-        """Return current word, i.e. word at cursor position"""
+    def get_current_word_and_position(self, completion=False):
+        """Return current word, i.e. word at cursor position,
+            and the start position"""
         cursor = self.textCursor()
 
         if cursor.hasSelection():
@@ -384,7 +385,13 @@ def is_special_character(move):
         # find a valid python variable name
         match = re.findall(r'([^\d\W]\w*)', text, re.UNICODE)
         if match:
-            return match[0]
+            return match[0], cursor.selectionStart()
+
+    def get_current_word(self, completion=False):
+        """Return current word, i.e. word at cursor position"""
+        ret = self.get_current_word_and_position(completion)
+        if ret is not None:
+            return ret[0]
 
     def get_current_line(self):
         """Return current line's text"""


Test patch:
diff --git a/spyder/plugins/editor/tests/test_introspection.py b/spyder/plugins/editor/tests/test_introspection.py
--- a/spyder/plugins/editor/tests/test_introspection.py
+++ b/spyder/plugins/editor/tests/test_introspection.py
@@ -11,6 +11,7 @@
 
 # Third party imports
 import pytest
+import pytestqt
 try:
     from unittest.mock import Mock
 except ImportError:
@@ -155,8 +156,63 @@ def test_introspection(setup_editor):
     assert "degrees(x)" in [x['label'] for x in sig.args[0]]
 
     qtbot.keyPress(completion, Qt.Key_Enter, delay=1000)
-    assert code_editor.toPlainText() == 'import math\nmath.degrees' \
-                                        '\nmath.degrees()\n'
+
+    # right for () + enter for new line
+    qtbot.keyPress(code_editor, Qt.Key_Right, delay=1000)
+    qtbot.keyPress(code_editor, Qt.Key_Right, delay=1000)
+    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=1000)
+
+    # Complete math.a <tab> ... s <enter> to math.asin
+    qtbot.keyClicks(code_editor, 'math.a')
+    qtbot.wait(20000)
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000) as sig:
+        qtbot.keyPress(code_editor, Qt.Key_Tab)
+    assert "asin(x)" in [x['label'] for x in sig.args[0]]
+    qtbot.keyClicks(completion, 's')
+    qtbot.keyPress(completion, Qt.Key_Enter, delay=1000)
+
+    # enter for new line
+    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=1000)
+
+    # Complete math.a <tab><enter> ... <enter> to math.acos
+    qtbot.keyClicks(code_editor, 'math.a')
+    qtbot.wait(20000)
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000) as sig:
+        qtbot.keyPress(code_editor, Qt.Key_Tab)
+        qtbot.keyPress(code_editor, Qt.Key_Enter)
+    assert "acos(x)" in [x['label'] for x in sig.args[0]]
+    qtbot.keyPress(completion, Qt.Key_Enter, delay=1000)
+
+    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=1000)
+
+    # Complete math.a <tab> s ...<enter> to math.asin
+    qtbot.keyClicks(code_editor, 'math.a')
+    qtbot.wait(20000)
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000) as sig:
+        qtbot.keyPress(code_editor, Qt.Key_Tab)
+        qtbot.keyPress(code_editor, 's')
+    assert "asin(x)" in [x['label'] for x in sig.args[0]]
+    qtbot.keyPress(completion, Qt.Key_Enter, delay=1000)
+
+    # Check math.a <tab> <backspace> doesn't emit sig_show_completions
+    qtbot.keyPress(code_editor, Qt.Key_Right, delay=1000)
+    qtbot.keyClicks(code_editor, 'math.a')
+    qtbot.wait(20000)
+    try:
+        with qtbot.waitSignal(completion.sig_show_completions,
+                              timeout=10000) as sig:
+            qtbot.keyPress(code_editor, Qt.Key_Tab)
+            qtbot.keyPress(code_editor, Qt.Key_Backspace)
+        raise RuntimeError("The signal should not have been recieved!")
+    except pytestqt.exceptions.TimeoutError:
+        pass
+
+    assert code_editor.toPlainText() == 'import math\nmath.degrees\n'\
+                                        'math.degrees()\nmath.asin\n'\
+                                        'math.acos\nmath.asin\nmath.\n'
 
     # Modify PYTHONPATH
     # editor.introspector.change_extra_path([LOCATION])