Repository: spyder-ide/spyder

Issue:
Autocomplete choice 1 option
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

If I have autocomplete but there is only one option, I would like this option to be selected automatically.

something like `math.de<TAB> ---->  math.degrees`
## Versions

* Spyder version: 4.0.0.dev0 8fbfb20e3
* Python version: 3.7.3
* Qt version: 5.9.1
* PyQt5 version: 5.9
* Operating System: Darwin 18.5.0

### Dependencies

```
pygments >=2.0    :  2.3.1 (OK)
qdarkstyle >=2.6.4:  2.6.5 (OK)
sphinx >=0.6.6    :  1.8.5 (OK)
psutil >=0.3      :  5.6.1 (OK)
pyls >=0.19.0     :  0.25.0 (OK)
nbconvert >=4.0   :  5.4.1 (OK)
pandas >=0.13.1   :  0.24.2 (OK)
numpy >=1.7       :  1.16.2 (OK)
sympy >=0.7.3     :  1.3 (OK)
cython >=0.21     :  0.29.6 (OK)
qtconsole >=4.2.0 :  4.5.0.dev0 (OK)
IPython >=4.0     :  7.3.0 (OK)
matplotlib >=2.0.0:  3.0.3 (OK)
pylint >=0.25     :  2.3.1 (OK)
```



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
@@ -46,55 +46,72 @@ def __init__(self, parent, ancestor):
         QListWidget.__init__(self, ancestor)
         self.setWindowFlags(Qt.SubWindow | Qt.FramelessWindowHint)
         self.textedit = parent
-        self.completion_list = None
         self.hide()
         self.itemActivated.connect(self.item_selected)
         self.currentRowChanged.connect(self.row_changed)
         self.is_internal_console = False
+        self.completion_list = None
+        self.completion_position = None
+        self.automatic = False
+        # Text to be displayed if no match is found.
+        self.empty_text = 'No match'
 
     def setup_appearance(self, size, font):
         self.resize(*size)
         self.setFont(font)
 
-    def show_list(self, completion_list, position):
+    def is_empty(self):
+        """Check if widget is empty."""
+        if self.count() == 0:
+            return True
+        if self.count() == 1 and self.item(0).text() == self.empty_text:
+            return True
+        return False
+
+    def show_list(self, completion_list, position, automatic):
+        """Show list corresponding to position."""
+
+        if not completion_list:
+            self.hide()
+            return
+
+        self.automatic = automatic
+
         if position is None:
             # Somehow the position was not saved.
             # Hope that the current position is still valid
-            self.position = self.textedit.textCursor().position()
+            self.completion_position = self.textedit.textCursor().position()
+
         elif self.textedit.textCursor().position() < position:
             # hide the text as we moved away from the position
+            self.hide()
             return
+
         else:
-            self.position = position
+            self.completion_position = position
 
         # Completions are handled differently for the Internal
         # console.
         if not isinstance(completion_list[0], dict):
             self.is_internal_console = True
         self.completion_list = completion_list
-        self.clear()
+        # Check everything is in order
+        self.update_current()
 
-        icons_map = {CompletionItemKind.PROPERTY: 'attribute',
-                     CompletionItemKind.VARIABLE: 'attribute',
-                     CompletionItemKind.METHOD: 'method',
-                     CompletionItemKind.FUNCTION: 'function',
-                     CompletionItemKind.CLASS: 'class',
-                     CompletionItemKind.MODULE: 'module',
-                     CompletionItemKind.CONSTRUCTOR: 'method',
-                     CompletionItemKind.REFERENCE: 'attribute'}
-
-        for completion in completion_list:
-            if not self.is_internal_console:
-                icon = icons_map.get(completion['kind'], 'no_match')
-                self.addItem(
-                    QListWidgetItem(ima.icon(icon), completion['insertText']))
-            else:
-                # This is used by the Internal console.
-                self.addItem(QListWidgetItem(completion[0]))
+        # If update_current called close, stop loading
+        if not self.completion_list:
+            return
 
-        self.setCurrentRow(0)
+        # If only one, must be chosen if not automatic
+        single_match = (self.count() == 1 and
+                        self.item(0).text() != self.empty_text)
+        if single_match and not self.automatic:
+            self.item_selected()
+            self.hide()
+            # signal used for testing
+            self.sig_show_completions.emit(completion_list)
+            return
 
-        QApplication.processEvents(QEventLoop.ExcludeUserInputEvents)
         self.show()
         self.setFocus()
         self.raise_()
@@ -150,32 +167,73 @@ def show_list(self, completion_list, position):
             for completion in completion_list:
                 completion['point'] = tooltip_point
 
-        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()
-
         # signal used for testing
         self.sig_show_completions.emit(completion_list)
 
+    def update_list(self, filter_text):
+        """
+        Update the displayed list by filtering self.completion_list.
+
+        If returns False, the autocompletion should stop.
+        """
+        self.clear()
+        icons_map = {CompletionItemKind.PROPERTY: 'attribute',
+                     CompletionItemKind.VARIABLE: 'attribute',
+                     CompletionItemKind.METHOD: 'method',
+                     CompletionItemKind.FUNCTION: 'function',
+                     CompletionItemKind.CLASS: 'class',
+                     CompletionItemKind.MODULE: 'module',
+                     CompletionItemKind.CONSTRUCTOR: 'method',
+                     CompletionItemKind.REFERENCE: 'attribute'}
+
+        for completion in self.completion_list:
+            if not self.is_internal_console:
+                completion_label = completion['filterText']
+                icon = icons_map.get(completion['kind'], 'no_match')
+                item = QListWidgetItem(ima.icon(icon),
+                                       completion['insertText'])
+            else:
+                completion_label = completion[0]
+                item = QListWidgetItem(completion_label)
+
+            if self.check_can_complete(
+                    completion_label, filter_text):
+                self.addItem(item)
+
+        if self.count() > 0:
+            self.setCurrentRow(0)
+            self.scrollTo(self.currentIndex(),
+                          QAbstractItemView.PositionAtTop)
+        else:
+            self.addItem(QListWidgetItem(self.empty_text))
+
     def hide(self):
+        """Hide the widget."""
+        self.completion_position = None
+        self.completion_list = None
+        self.clear()
         QToolTip.hideText()
         QListWidget.hide(self)
         self.textedit.setFocus()
 
     def keyPressEvent(self, event):
+        """Process keypress."""
         text, key = event.text(), event.key()
         alt = event.modifiers() & Qt.AltModifier
         shift = event.modifiers() & Qt.ShiftModifier
         ctrl = event.modifiers() & Qt.ControlModifier
         modifier = shift or ctrl or alt
-        if key in (Qt.Key_Return, Qt.Key_Enter) or key == Qt.Key_Tab:
-            self.item_selected()
+        if key in (Qt.Key_Return, Qt.Key_Enter, Qt.Key_Tab):
+            # Check that what was selected can be selected,
+            # otherwise timing issues
+            if self.up_to_date():
+                self.item_selected()
+            else:
+                self.hide()
+                self.textedit.keyPressEvent(event)
         elif key == Qt.Key_Escape:
             self.hide()
-        elif key in (Qt.Key_Return, Qt.Key_Enter,
-                     Qt.Key_Left, Qt.Key_Right) or text in ('.', ':'):
+        elif key in (Qt.Key_Left, Qt.Key_Right) or text in ('.', ':'):
             self.hide()
             self.textedit.keyPressEvent(event)
         elif key in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown,
@@ -188,39 +246,75 @@ def keyPressEvent(self, event):
             else:
                 QListWidget.keyPressEvent(self, event)
         elif len(text) or key == Qt.Key_Backspace:
-            # 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()
+            self.textedit.keyPressEvent(event)
+            self.update_current()
         elif modifier:
             self.textedit.keyPressEvent(event)
         else:
             self.hide()
             QListWidget.keyPressEvent(self, event)
 
-    def update_current(self):
-        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:
-                    completion_label = completion['filterText']
-                else:
-                    completion_label = completion[0]
+    def up_to_date(self):
+        """
+        Check if the selection is up to date.
+        """
+        if self.is_empty():
+            return False
+        if not self.is_position_correct():
+            return False
+        completion_text = self.textedit.get_current_word(completion=True)
+        selected_text = self.currentItem().text()
+        return self.check_can_complete(selected_text, completion_text)
 
-                if completion_label.startswith(completion_text):
-                    self.setCurrentRow(row)
-                    self.scrollTo(self.currentIndex(),
-                                  QAbstractItemView.PositionAtTop)
-                    break
-        else:
+    def check_can_complete(self, text, sub):
+        """Check if sub can be completed to text."""
+        if not sub:
+            return True
+        return to_text_string(text).lower().startswith(
+                to_text_string(sub).lower())
+
+    def is_position_correct(self):
+        """Check if the position is correct."""
+
+        if self.completion_position is None:
+            return False
+
+        cursor_position = self.textedit.textCursor().position()
+
+        # Can only go forward from the data we have
+        if cursor_position < self.completion_position:
+            return False
+
+        completion_text = self.textedit.get_current_word_and_position(
+            completion=True)
+
+        # If no text found, we must be at self.completion_position
+        if completion_text is None:
+            if self.completion_position == cursor_position:
+                return True
+            else:
+                return False
+
+        completion_text, text_position = completion_text
+        completion_text = to_text_string(completion_text)
+
+        # The position of text must compatible with completion_position
+        if not text_position <= self.completion_position <= (
+                text_position + len(completion_text)):
+            return False
+
+        return True
+
+    def update_current(self):
+        """
+        Update the displayed list.
+        """
+        if not self.is_position_correct():
             self.hide()
+            return
+
+        completion_text = self.textedit.get_current_word(completion=True)
+        self.update_list(completion_text)
 
     def focusOutEvent(self, event):
         event.ignore()
@@ -239,23 +333,26 @@ def focusOutEvent(self, event):
                 pass
 
     def item_selected(self, item=None):
+        """Perform the item selected action."""
         if item is None:
             item = self.currentItem()
-        self.textedit.insert_completion(to_text_string(item.text()),
-                                        self.position)
+
+        if item is not None and self.completion_position is not None:
+            self.textedit.insert_completion(to_text_string(item.text()),
+                                            self.completion_position)
         self.hide()
 
     @Slot(int)
     def row_changed(self, row):
-        item = self.completion_list[row]
-        if len(item['documentation']) > 0:
-            # TODO: LSP - Define an UI element to display the documentation
-            # self.textedit.show_calltip(
-            #     item['detail'], item['documentation'], color='#daa520',
-            #     at_point=item['point'])
-            pass
-        else:
-            QToolTip.hideText()
+        if self.completion_list:
+            item = self.completion_list[row]
+            if len(item['documentation']) > 0:
+                # TODO: LSP - Define an UI element to display the documentation
+                # self.textedit.show_calltip(
+                #     item['detail'], item['documentation'], color='#daa520',
+                #     at_point=item['point'])
+                return
+        QToolTip.hideText()
 
 
 class TextEditBaseWidget(QPlainTextEdit, BaseEditMixin):
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
@@ -508,7 +508,7 @@ def __init__(self, parent=None):
         self.range_formatting_enabled = False
         self.formatting_characters = []
         self.rename_support = False
-        self.last_completion_position = None
+        self.completion_args = None
 
         # Editor Extensions
         self.editor_extensions = EditorExtensionsManager(self)
@@ -885,19 +885,25 @@ def do_completion(self, automatic=False):
             'line': line,
             'column': column
         }
-        self.last_completion_position = self.textCursor().position()
+        self.completion_args = (self.textCursor().position(), automatic)
         return params
 
     @handles(LSPRequestTypes.DOCUMENT_COMPLETION)
     def process_completion(self, params):
         """Handle completion response."""
+        args = self.completion_args
+        if args is None:
+            # This should not happen
+            return
+        self.completion_args = None
+        position, automatic = args
         try:
             completions = params['params']
             if completions is not None and len(completions) > 0:
                 completion_list = sorted(completions,
                                          key=lambda x: x['sortText'])
-                position = self.last_completion_position
-                self.completion_widget.show_list(completion_list, position)
+                self.completion_widget.show_list(
+                        completion_list, position, automatic)
         except Exception:
             self.log_lsp_handle_errors('Error when processing completions')
 


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_introspection.py b/spyder/plugins/editor/widgets/tests/test_introspection.py
--- a/spyder/plugins/editor/widgets/tests/test_introspection.py
+++ b/spyder/plugins/editor/widgets/tests/test_introspection.py
@@ -46,9 +46,8 @@ def test_space_completion(lsp_codeeditor, qtbot):
         qtbot.keyPress(code_editor, Qt.Key_Tab)
     assert "import" in [x['label'] for x in sig.args[0]]
 
-    # enter should accept first completion
-    qtbot.keyPress(completion, Qt.Key_Enter, delay=1000)
     assert code_editor.toPlainText() == 'from numpy import'
+    assert not completion.isVisible()
 
 
 @pytest.mark.slow
@@ -64,8 +63,8 @@ def test_hide_widget_completion(lsp_codeeditor, qtbot):
     # Set cursor to start
     code_editor.go_to_line(1)
 
-    # Complete from numpy --> from numpy import
-    qtbot.keyClicks(code_editor, 'from numpy ')
+    # Complete from numpy import --> from numpy import ?
+    qtbot.keyClicks(code_editor, 'from numpy import ')
     with qtbot.waitSignal(code_editor.lsp_response_signal, timeout=30000):
         code_editor.document_did_change()
 
@@ -123,7 +122,6 @@ def test_completions(lsp_codeeditor, qtbot):
         qtbot.keyPress(code_editor, Qt.Key_Tab)
     assert "degrees(x)" in [x['label'] for x in sig.args[0]]
 
-    qtbot.keyPress(completion, Qt.Key_Enter, delay=300)
     assert code_editor.toPlainText() == 'import math\nmath.degrees'
 
     # enter for new line
@@ -141,8 +139,6 @@ def test_completions(lsp_codeeditor, qtbot):
         qtbot.keyPress(code_editor, Qt.Key_Tab)
     assert "degrees(x)" in [x['label'] for x in sig.args[0]]
 
-    qtbot.keyPress(completion, Qt.Key_Enter, delay=300)
-
     # right for () + enter for new line
     qtbot.keyPress(code_editor, Qt.Key_Right, delay=300)
     qtbot.keyPress(code_editor, Qt.Key_Right, delay=300)
@@ -157,24 +153,31 @@ def test_completions(lsp_codeeditor, qtbot):
                           timeout=10000) as sig:
         qtbot.keyPress(code_editor, Qt.Key_Tab)
     assert "asin(x)" in [x['label'] for x in sig.args[0]]
+    # Test if the list is updated
+    assert "acos(x)" == completion.completion_list[0]['label']
     qtbot.keyClicks(completion, 's')
+    assert "asin" == completion.item(0).text()
     qtbot.keyPress(completion, Qt.Key_Enter, delay=300)
 
     # enter for new line
     qtbot.keyPress(code_editor, Qt.Key_Enter, delay=300)
 
-    # Complete math.a <tab><enter> ... <enter> to math.acos
-    qtbot.keyClicks(code_editor, 'math.a')
+    # Check can get list back
+    qtbot.keyClicks(code_editor, 'math.c')
     with qtbot.waitSignal(code_editor.lsp_response_signal, timeout=30000):
         code_editor.document_did_change()
 
     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=300)
+    assert completion.count() == 4
+    assert "ceil(x)" in [x['label'] for x in sig.args[0]]
+    qtbot.keyClicks(completion, 'e')
+    assert completion.count() == 1
+    qtbot.keyPress(completion, Qt.Key_Backspace)
+    assert completion.count() == 4
 
+    # enter for new line
     qtbot.keyPress(code_editor, Qt.Key_Enter, delay=300)
 
     # Complete math.a <tab> s ...<enter> to math.asin
@@ -190,23 +193,36 @@ def test_completions(lsp_codeeditor, qtbot):
     qtbot.keyPress(completion, Qt.Key_Enter, delay=300)
 
     # Check math.a <tab> <backspace> doesn't emit sig_show_completions
-    qtbot.keyPress(code_editor, Qt.Key_Right, delay=300)
+    qtbot.keyPress(code_editor, Qt.Key_Enter, delay=300)
     qtbot.keyClicks(code_editor, 'math.a')
     with qtbot.waitSignal(code_editor.lsp_response_signal, timeout=30000):
         code_editor.document_did_change()
 
     try:
         with qtbot.waitSignal(completion.sig_show_completions,
-                              timeout=10000) as sig:
+                              timeout=5000) as sig:
             qtbot.keyPress(code_editor, Qt.Key_Tab)
             qtbot.keyPress(code_editor, Qt.Key_Backspace)
         raise RuntimeError("The signal should not have been received!")
     except pytestqt.exceptions.TimeoutError:
         pass
 
+    try:
+        with qtbot.waitSignal(completion.sig_show_completions,
+                              timeout=5000) as sig:
+            qtbot.keyPress(code_editor, Qt.Key_Tab)
+            qtbot.keyPress(code_editor, Qt.Key_Return)
+        raise RuntimeError("The signal should not have been received!")
+    except pytestqt.exceptions.TimeoutError:
+        pass
+
     assert code_editor.toPlainText() == 'import math\nmath.degrees\n'\
                                         'math.degrees()\nmath.asin\n'\
-                                        'math.acos\nmath.asin\nmath.'
+                                        'math.c\nmath.asin\nmath.\n'
+
+
+if __name__ == '__main__':
+    pytest.main(['test_introspection.py', '--run-slow'])
 
     # Modify PYTHONPATH
     # editor.introspector.change_extra_path([LOCATION])