Repository: spyder-ide/spyder

Issue:
Crashing in kite_tutorial.py
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

On line 147, "plt.plo"
Press "t", choose plt.plot(...)
Press ctrl+enter
Choose plt.plot(my_x, my_y)

### Traceback
```python-traceback
  File "/home/kirill/anaconda3/envs/py37tf13/lib/python3.7/site-packages/spyder/plugins/editor/extensions/snippets.py", line 860, in insert_snippet
    leaf, snippet_root, ast, line, column)
  File "/home/kirill/anaconda3/envs/py37tf13/lib/python3.7/site-packages/spyder/plugins/editor/extensions/snippets.py", line 489, in _insert_snippet_at_node
    list(possible_snippet.placeholder) +
TypeError: 'TextNode' object is not iterable
WARNING:spyder.widgets.github.backend:Failed to send bug report on Github. response={'code': 401, 'json': {'message': 'Bad credentials', 'documentation_url': 'https://developer.github.com/v3'}}
```

## Versions

* Spyder version: 4.0.0 
* Python version: 3.7.5
* Qt version: 5.9.6
* PyQt5 version: 5.9.2
* Operating System: Linux 5.0.0-37-generic

### Dependencies

```
cloudpickle >=0.5.0          :  1.2.2 (OK)
pygments >=2.0               :  2.5.1 (OK)
qtconsole >=4.6.0            :  4.6.0 (OK)
nbconvert >=4.0              :  5.6.1 (OK)
sphinx >=0.6.6               :  2.2.2 (OK)
pylint >=0.25                :  2.4.4 (OK)
psutil >=0.3                 :  5.6.7 (OK)
qtawesome >=0.5.7            :  0.6.0 (OK)
qtpy >=1.5.0                 :  1.9.0 (OK)
pickleshare >=0.4            :  0.7.5 (OK)
zmq >=17                     :  18.1.1 (OK)
chardet >=2.0.0              :  3.0.4 (OK)
numpydoc >=0.6.0             :  0.9.1 (OK)
spyder_kernels >=1.8.1;<2.0.0:  1.8.1 (OK)
qdarkstyle >=2.7             :  2.7 (OK)
atomicwrites >=1.2.0         :  1.3.0 (OK)
diff_match_patch >=20181111  :  20181111 (OK)
intervaltree                 :  None (OK)
watchdog                     :  None (OK)
keyring                      :  None (OK)
pexpect >=4.4.0              :  4.7.0 (OK)
pympler                      :  None (OK)
sympy >=0.7.3                :  None (NOK)
cython >=0.21                :  0.29.14 (OK)
IPython >=4.0                :  7.10.0 (OK)
matplotlib >=2.0.0           :  3.1.1 (OK)
pandas >=0.13.1              :  None (NOK)
numpy >=1.7                  :  1.17.4 (OK)
scipy >=0.17.0               :  1.3.1 (OK)
pyls >=0.31.2;<0.32.0        :  0.31.2 (OK)
xdg >=0.26                   :  0.26 (OK)
rtree >=0.8.3                :  0.8.3 (OK)
```



Discussion:

Resolving patch:
diff --git a/spyder/plugins/editor/extensions/snippets.py b/spyder/plugins/editor/extensions/snippets.py
--- a/spyder/plugins/editor/extensions/snippets.py
+++ b/spyder/plugins/editor/extensions/snippets.py
@@ -486,7 +486,7 @@ def _insert_snippet_at_node(self, leaf, snippet, new_node,
                 if isinstance(possible_snippet, nodes.SnippetASTNode):
                     # Placeholder replacement
                     first_tokens = (
-                        list(possible_snippet.placeholder) +
+                        list(possible_snippet.placeholder.tokens) +
                         list(new_node.tokens[1:])
                     )
                     second_tokens = []


Test patch:
diff --git a/spyder/plugins/completion/languageserver/tests/conftest.py b/spyder/plugins/completion/languageserver/tests/conftest.py
--- a/spyder/plugins/completion/languageserver/tests/conftest.py
+++ b/spyder/plugins/completion/languageserver/tests/conftest.py
@@ -16,6 +16,7 @@
 app = qapplication()
 
 from qtpy.QtCore import QObject, Signal, Slot
+from qtpy.QtWidgets import QWidget
 import pytest
 from pytestqt.plugin import QtBot
 
@@ -63,7 +64,7 @@ def stop_lsp_services(self):
 
 
 class MainWindowMock(QObject):
-    """Mock for the Main Window."""
+    """Mock for the Main Window as a widget."""
     def __init__(self):
         QObject.__init__(self)
         self.editor = EditorMock()
@@ -79,6 +80,23 @@ def __getattr__(self, attr):
             return Mock()
 
 
+class MainWindowWidgetMock(QWidget):
+    """Mock for the Main Window."""
+    def __init__(self):
+        QWidget.__init__(self)
+        self.editor = EditorMock()
+        self.projects = ProjectsMock()
+
+    def __getattr__(self, attr):
+        if attr == 'editor':
+            return self.editor
+        elif attr == 'projects':
+            # TODO: Add tests for project switching
+            return self.projects
+        else:
+            return Mock()
+
+
 @pytest.fixture(scope="module")
 def qtbot_module(qapp, request):
     """Module fixture for qtbot."""
diff --git a/spyder/plugins/editor/widgets/tests/conftest.py b/spyder/plugins/editor/widgets/tests/conftest.py
--- a/spyder/plugins/editor/widgets/tests/conftest.py
+++ b/spyder/plugins/editor/widgets/tests/conftest.py
@@ -24,7 +24,7 @@
 from spyder.plugins.editor.tests.conftest import (
     editor_plugin, editor_plugin_open_files, python_files)
 from spyder.plugins.completion.languageserver.tests.conftest import (
-    qtbot_module, MainWindowMock)
+    qtbot_module, MainWindowMock, MainWindowWidgetMock)
 from spyder.plugins.editor.widgets.codeeditor import CodeEditor
 from spyder.plugins.editor.widgets.editor import EditorStack
 from spyder.plugins.completion.plugin import CompletionManager
@@ -100,6 +100,50 @@ def teardown():
     return editor, fallback
 
 
+@pytest.fixture
+def kite_codeeditor(qtbot_module, request):
+    """
+    CodeEditor instance with Kite enabled.
+
+    NOTE: This fixture only works if used with kite installed.
+    If running in the CI, the installation of Kite could be accomplished by
+    spyder/plugins/completion/kite/utils/tests/test_install.py::test_kite_install
+
+    Any test running with this fixture should run after the installation
+    test mentioned above.
+    """
+    main = MainWindowWidgetMock()
+    completions = CompletionManager(main, ['kite'])
+    completions.start()
+    completions.start_client('python')
+    qtbot_module.addWidget(completions)
+
+    # Create a CodeEditor instance
+    editor = codeeditor_factory()
+    qtbot_module.addWidget(editor)
+    editor.show()
+
+    # Redirect editor fallback requests to FallbackActor
+    editor.sig_perform_completion_request.connect(completions.send_request)
+    editor.filename = 'test.py'
+    editor.language = 'Python'
+    editor.completions_available = True
+    qtbot_module.wait(2000)
+
+    def teardown():
+        completions.shutdown()
+        editor.hide()
+        editor.completion_widget.hide()
+
+    request.addfinalizer(teardown)
+    kite = completions.get_client('kite')
+    CONF.set('kite', 'show_installation_dialog', False)
+    CONF.set('kite', 'show_onboarding', False)
+    CONF.set('kite', 'call_to_action', False)
+    kite.update_configuration()
+    return editor, kite
+
+
 # Windows tests fail if using module scope
 if os.name == 'nt':
     LSP_PLUGIN_SCOPE = 'module'
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
@@ -31,6 +31,8 @@
 from spyder.plugins.completion.languageserver import (
     LSPRequestTypes, CompletionItemKind)
 from spyder.plugins.completion.kite.providers.document import KITE_COMPLETION
+from spyder.plugins.completion.kite.utils.status import (
+    check_if_kite_installed, check_if_kite_running)
 from spyder.py3compat import PY2
 from spyder.config.manager import CONF
 
@@ -730,6 +732,95 @@ def test_func(xlonger, y1, some_z):
     code_editor.toggle_code_snippets(True)
 
 
+@pytest.mark.slow
+@pytest.mark.skipif((not rtree_available
+                     or not check_if_kite_installed()
+                     or not check_if_kite_running()),
+                    reason="Only works if rtree is installed."
+                           "It's not meant to be run without kite installed "
+                           "and runnning")
+def test_kite_code_snippets(kite_codeeditor, qtbot):
+    """
+    Test kite code snippets completions without initial placeholder.
+
+    See spyder-ide/spyder#10971
+    """
+    assert rtree_available
+    code_editor, kite = kite_codeeditor
+    completion = code_editor.completion_widget
+    snippets = code_editor.editor_extensions.get('SnippetsExtension')
+
+    CONF.set('lsp-server', 'code_snippets', True)
+    CONF.set('kite', 'enable', True)
+    kite.update_configuration()
+
+    code_editor.toggle_automatic_completions(False)
+    code_editor.toggle_code_snippets(True)
+    # Set cursor to start
+    code_editor.go_to_line(1)
+
+    text = """
+    import numpy as np
+    """
+    text = textwrap.dedent(text)
+    code_editor.insert_text(text)
+
+    qtbot.keyClicks(code_editor, 'np.sin')
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000) as sig:
+        qtbot.keyPress(code_editor, Qt.Key_Tab, delay=300)
+
+    assert 'sin('+u'\u2026'+')' in {
+        x['label'] for x in sig.args[0]}
+
+    expected_insert = 'sin($1)$0'
+    insert = sig.args[0][0]
+    assert expected_insert == insert['insertText']
+
+    # Insert completion
+    qtbot.keyPress(completion, Qt.Key_Tab)
+    assert snippets.is_snippet_active
+
+    # Get code selected text
+    cursor = code_editor.textCursor()
+    arg1 = cursor.selectedText()
+    assert '' == arg1
+    assert snippets.active_snippet == 1
+
+    code_editor.set_cursor_position('eol')
+    code_editor.move_cursor(-1)
+
+    with qtbot.waitSignal(completion.sig_show_completions,
+                          timeout=10000) as sig2:
+        qtbot.keyPress(code_editor, Qt.Key_Space, modifier=Qt.ControlModifier,
+                       delay=300)
+    assert '<x>)' in {x['label'] for x in sig2.args[0]}
+
+    expected_insert = '${1:[x]})$0'
+    insert = sig2.args[0][0]
+    assert expected_insert == insert['textEdit']['newText']
+    qtbot.keyPress(completion, Qt.Key_Tab)
+
+    # Snippets are disabled when there are no more left
+    code_editor.set_cursor_position('eol')
+    qtbot.keyPress(code_editor, Qt.Key_Enter)
+    assert not snippets.is_snippet_active
+
+    cursor = code_editor.textCursor()
+    cursor.movePosition(QTextCursor.PreviousBlock)
+    cursor.movePosition(QTextCursor.StartOfBlock)
+    cursor.movePosition(QTextCursor.EndOfBlock, mode=QTextCursor.KeepAnchor)
+    text1 = cursor.selectedText()
+    assert text1 == 'np.sin([x])'
+
+    CONF.set('lsp-server', 'code_snippets', False)
+    CONF.set('kite', 'enable', False)
+    kite.update_configuration()
+
+    code_editor.toggle_automatic_completions(True)
+    code_editor.toggle_code_snippets(True)
+
+
 @pytest.mark.slow
 @pytest.mark.first
 @flaky(max_runs=5)