Repository: spyder-ide/spyder

Issue:
Error when getting hover of "dict"
## Description

### What steps will reproduce the problem?

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

I got the following error after writing

```python
dict()
```

in the editor and placing the mouse on top of it.

### Traceback
```python-traceback
    at_point=self._last_point)
    signature = self._format_signature(signature)
    parameter=parameter,
IndexError: tuple index out of range

Traceback (most recent call last):
    msg = self.format(record)
    return fmt.format(record)
    record.message = record.getMessage()
    msg = msg % self.args
ValueError: incomplete format
Call stack:
    start.main()
    mainwindow.main()
    mainwindow = run_spyder(app, options, args)
    app.exec_()
    handler(resp['result'], req_id)
    {'params': contents})
    handler(params)
    self.log_lsp_handle_errors("Error when processing hover")
    logger.error('%', 1, stack_info=True)
Arguments: (1,)
```

## Versions

* Spyder version: 4.0.0.dev0 061f7c3d0
* Python version: 3.6.6
* Qt version: 5.9.6
* PyQt5 version: 5.9.2
* Operating System: Linux 4.13.16-041316-generic

### Dependencies

```
pygments >=2.0    :  2.3.1 (OK)
qdarkstyle >=2.6.4:  2.6.5 (OK)
sphinx >=0.6.6    :  1.8.2 (OK)
psutil >=0.3      :  5.4.8 (OK)
pyls >=0.19.0     :  0.90.0.dev0 (OK)
nbconvert >=4.0   :  5.4.1 (OK)
pandas >=0.13.1   :  0.23.4 (OK)
numpy >=1.7       :  1.15.4 (OK)
sympy >=0.7.3     :  1.3 (OK)
cython >=0.21     :  0.29.2 (OK)
qtconsole >=4.2.0 :  4.4.2 (OK)
IPython >=4.0     :  7.3.0 (OK)
matplotlib >=2.0.0:  3.0.2 (OK)
pylint >=0.25     :  2.1.1 (OK)
```



Discussion:
@goanpeca, please take a look at this one.
I'm also getting the following error when getting the signature of `dict`:

```python-traceback
    parameter=parameter,
    parameter=parameter,
    parameter=parameter,
IndexError: tuple index out of range

Traceback (most recent call last):
    msg = self.format(record)
    return fmt.format(record)
    record.message = record.getMessage()
    msg = msg % self.args
ValueError: incomplete format
Call stack:
    start.main()
    mainwindow.main()
    mainwindow = run_spyder(app, options, args)
    app.exec_()
    handler(resp['result'], req_id)
    {'params': response})
    handler(params)
    self.log_lsp_handle_errors("Error when processing signature")
    logger.error('%', 1, stack_info=True)
Arguments: (1,)
```

It seems to be the originated by the same cause.
@ccordoba12 I am not sure how to handle this since the original logic was expecting something like from the LSP:

```python
'Popen(self, args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0, restore_signals=True, start_new_session=False, pass_fds=(), *, encoding=None, errors=None, text=None)'
```

But for some builtins (that allow different ways of calling)
we get 

```python
'dict() -> new empty dictionary dict(mapping) -> new dictionary initialized from a mapping object's (key, value) pairs dict(iterable) -> new dictionary initialized as if via: d = {} for k, v in iterable: d[k] = v dict(**kwargs) -> new dictionary initialized with the name=value pairs in the keyword argument list.  For example:  dict(one=1, two=2)'
```

Which is not really a signature, but a dosctring, so how should we handle these cases, since the highlight of parameter logic expects 1 signature, not more than that.
I could check for now for `->` which seems a specific character of these methods and simply not color  anything.
> I could check for now for -> which seems a specific character of these methods and simply not color anything.

I think that's not robust enough. The simplest solution should be: if you can't find a signature, simply return the docstring.

Resolving patch:
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
@@ -279,7 +279,7 @@ class CodeEditor(TextEditBaseWidget):
 
     #: Signal only used for tests
     # TODO: Remove it!
-    sig_signature_invoked = Signal()
+    sig_signature_invoked = Signal(dict)
 
     #: Signal emmited when processing code analysis warnings is finished
     sig_process_code_analysis = Signal()
@@ -921,8 +921,7 @@ def process_signatures(self, params):
             signature_params = params['params']
             if (signature_params is not None and
                     'activeParameter' in signature_params):
-                self.sig_signature_invoked.emit()
-
+                self.sig_signature_invoked.emit(signature_params)
                 signature_data = signature_params['signatures']
                 documentation = signature_data['documentation']
 
@@ -962,12 +961,14 @@ def handle_hover_response(self, contents):
         """Handle hover response."""
         try:
             content = contents['params']
-            self.sig_display_object_info.emit(content)
 
-            if CONF.get('lsp-server', 'enable_hover_hints') and content:
-                if self._show_hint and self._last_point:
+            if CONF.get('lsp-server', 'enable_hover_hints'):
+                self.sig_display_object_info.emit(content)
+
+                if self._show_hint and self._last_point and content:
                     # This is located in spyder/widgets/mixins.py
                     word = self._last_hover_word,
+                    content = content.replace(u'\xa0', ' ')
                     self.show_hint(content, inspect_word=word,
                                    at_point=self._last_point)
                     self._last_point = None
@@ -1632,6 +1633,7 @@ def set_text(self, text):
         """Set the text of the editor"""
         self.setPlainText(text)
         self.set_eol_chars(text)
+        self.document_did_change(text)
         #if self.supported_language:
             #self.highlighter.rehighlight()
 
diff --git a/spyder/widgets/mixins.py b/spyder/widgets/mixins.py
--- a/spyder/widgets/mixins.py
+++ b/spyder/widgets/mixins.py
@@ -247,7 +247,7 @@ def _format_text(self, title=None, signature=None, text=None,
 
         return template
 
-    def _format_signature(self, signature, parameter=None,
+    def _format_signature(self, signatures, parameter=None,
                           parameter_color=_PARAMETER_HIGHLIGHT_COLOR,
                           char_color=_CHAR_HIGHLIGHT_COLOR):
         """
@@ -281,51 +281,123 @@ def handle_sub(matchobj):
             new = match.replace(parameter, active_parameter_template)
             return new
 
-        # Remove duplicate spaces
-        signature = ' '.join(signature.split())
+        if not isinstance(signatures, list):
+            signatures = [signatures]
 
-        # Replace ay initial spaces
-        signature = signature.replace('( ', '(')
+        new_signatures = []
+        for signature in signatures:
+            # Remove duplicate spaces
+            signature = ' '.join(signature.split())
 
-        # Process signature template
-        if parameter:
-            pattern = r'[\*|(|\s](' + parameter + r')[,|)|\s|=]'
+            # Replace initial spaces
+            signature = signature.replace('( ', '(')
 
-        formatted_lines = []
-        name = signature.split('(')[0]
-        indent = ' ' * (len(name) + 1)
-        rows = textwrap.wrap(signature, width=60, subsequent_indent=indent)
-        for row in rows:
+            # Process signature template
             if parameter:
-                # Add template to highlight the active parameter
-                row = re.sub(pattern, handle_sub, row)
+                pattern = r'[\*|(|\s](' + parameter + r')[,|)|\s|=]'
+
+            formatted_lines = []
+            name = signature.split('(')[0]
+            indent = ' ' * (len(name) + 1)
+            rows = textwrap.wrap(signature, width=60, subsequent_indent=indent)
+            for row in rows:
+                if parameter:
+                    # Add template to highlight the active parameter
+                    row = re.sub(pattern, handle_sub, row)
+
+                row = row.replace(' ', '&nbsp;')
+                row = row.replace('span&nbsp;', 'span ')
+
+                language = getattr(self, 'language', None)
+                if language and 'python' == language.lower():
+                    for char in ['(', ')', ',', '*', '**']:
+                        new_char = chars_template.format(char=char)
+                        row = row.replace(char, new_char)
+
+                formatted_lines.append(row)
+            title_template = '<br>'.join(formatted_lines)
+
+            # Get current font properties
+            font = self.font()
+            font_size = font.pointSize()
+            font_family = font.family()
+
+            # Format title to display active parameter
+            if parameter:
+                title = title_template.format(
+                    font_size=font_size,
+                    font_family=font_family,
+                    color=parameter_color,
+                    parameter=parameter,
+                )
+            else:
+                title = title_template
+            new_signatures.append(title)
 
-            row = row.replace(' ', '&nbsp;')
-            row = row.replace('span&nbsp;', 'span ')
+        return '<br><br>'.join(new_signatures)
 
-            language = getattr(self, 'language', None)
-            if language and 'python' == language.lower():
-                for char in ['(', ')', ',', '*', '**']:
-                    new_char = chars_template.format(char=char)
-                    row = row.replace(char, new_char)
+    def _check_signature_and_format(self, signature_or_text, parameter=None):
+        """
+        LSP hints might provide docstrings instead of signatures.
 
-            formatted_lines.append(row)
-        title_template = '<br>'.join(formatted_lines)
+        This method will check for multiple signatures (dict, type etc...) and
+        format the text accordingly.
+        """
+        open_func_char = ''
+        has_signature = False
+        has_multisignature = False
+        language = getattr(self, 'language', '').lower()
+        lines = signature_or_text.split('\n')
+
+        if language == 'python':
+            open_func_char = '('
+            idx = signature_or_text.find(open_func_char)
+            inspect_word = signature_or_text[:idx]
+            name_plus_char = inspect_word + open_func_char
+
+            # Signature type
+            count = signature_or_text.count(name_plus_char)
+            has_signature = open_func_char in lines[0]
+            has_multisignature = count > 1
+
+        if has_signature and not has_multisignature:
+            for i, line in enumerate(lines):
+                if line.strip() == '':
+                    break
 
-        # Get current font properties
-        font = self.font()
-        font_size = font.pointSize()
-        font_family = font.family()
+            if i == 0:
+                signature = lines[0]
+                extra_text = None
+            else:
+                signature = '\n'.join(lines[:i])
+                extra_text = '\n'.join(lines[i:])
 
-        # Format title to display active parameter
-        title = title_template.format(
-            font_size=font_size,
-            font_family=font_family,
-            color=parameter_color,
-            parameter=parameter,
-        )
+            if signature:
+                new_signature = self._format_signature(
+                    signatures=signature,
+                    parameter=parameter,
+                )
+        elif has_multisignature:
+            signature = signature_or_text.replace(name_plus_char,
+                                                  '<br>' + name_plus_char)
+            signature = signature[4:]  # Remove the first line break
+            signature = signature.replace('\n', ' ')
+            signature = signature.replace(r'\\*', '*')
+            signature = signature.replace(r'\*', '*')
+            signature = signature.replace('<br>', '\n')
+            signatures = signature.split('\n')
+            signatures = [sig for sig in signatures if sig]  # Remove empty
+
+            new_signature = self._format_signature(
+                signatures=signatures,
+                parameter=parameter,
+            )
+            extra_text = None
+        else:
+            new_signature = None
+            extra_text = signature_or_text
 
-        return title
+        return new_signature, extra_text, inspect_word
 
     def show_calltip(self, signature, parameter=None):
         """
@@ -338,19 +410,15 @@ def show_calltip(self, signature, parameter=None):
         # Find position of calltip
         point = self._calculate_position()
 
-        # Format signature
-        html_signature = self._format_signature(
-            signature=signature,
-            parameter=parameter,
-        )
-
-        inspect_word = signature.split('(')[0]
+        # Format
+        res = self._check_signature_and_format(signature, parameter)
+        new_signature, text, inspect_word = res
         text = self._format_text(
-            signature=html_signature,
+            signature=new_signature,
+            text=text,
             inspect_word=inspect_word,
             display_link=False,
         )
-
         self._update_stylesheet(self.calltip_widget)
 
         # Show calltip
@@ -387,29 +455,9 @@ def show_tooltip(self, title=None, signature=None, text=None,
 
     def show_hint(self, text, inspect_word, at_point):
         """Show code hint and crop text as needed."""
-        # Check language
-        lines = text.split('\n')
-        is_signature = False
-        if 'python' in self.language.lower():
-            is_signature = '(' in lines[0]
-
-        # Split signature and the rest
-        signature, additional_text = None, text
-        if is_signature:
-            for i, line in enumerate(lines):
-                if line.strip() == '':
-                    break
-            if i == 0:
-                signature = lines[0]
-                additional_text = None
-            else:
-                signature = '\n'.join(lines[:i])
-                additional_text = '\n'.join(lines[i:])
-
-            if signature:
-                signature = self._format_signature(signature)
-
         # Check if signature and format
+        res = self._check_signature_and_format(text)
+        html_signature, extra_text, _ = res
         point = self.get_word_start_pos(at_point)
 
         # This is needed to get hover hints
@@ -417,7 +465,7 @@ def show_hint(self, text, inspect_word, at_point):
         cursor.movePosition(QTextCursor.StartOfWord, QTextCursor.MoveAnchor)
         self._last_hover_cursor = cursor
 
-        self.show_tooltip(signature=signature, text=additional_text,
+        self.show_tooltip(signature=html_signature, text=extra_text,
                           at_point=point, inspect_word=inspect_word,
                           display_link=True, max_lines=10)
 


Test patch:
diff --git a/spyder/plugins/editor/widgets/tests/test_hints_and_calltips.py b/spyder/plugins/editor/widgets/tests/test_hints_and_calltips.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/editor/widgets/tests/test_hints_and_calltips.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+"""Tests for editor calltips and hover hints tooltips."""
+
+# Standard library imports
+import sys
+
+# Third party imports
+from qtpy.QtCore import Qt, QPoint
+from qtpy.QtGui import QTextCursor
+import pytest
+
+# Constants
+PY2 = sys.version[0] == '2'
+TEST_SIG = 'some_function(hello=None)'
+TEST_DOCSTRING = "This is the test docstring."
+TEST_TEXT = """'''Testing something'''
+def {SIG}:
+    '''{DOC}'''
+
+
+some_function""".format(SIG=TEST_SIG, DOC=TEST_DOCSTRING)
+
+
+@pytest.mark.slow
+@pytest.mark.second
+@pytest.mark.parametrize('params', [
+            # Parameter, Expected Output
+            ('dict', 'dict'),
+            ('type', 'type'),
+            (TEST_TEXT, TEST_SIG)
+        ]
+    )
+def test_get_calltips(qtbot, lsp_codeeditor, params):
+    """Test that the editor is returning hints."""
+    code_editor, _ = lsp_codeeditor
+
+    param, expected_output_text = params
+
+    # Set text in editor
+    code_editor.set_text(param)
+
+    # Move cursor to end of line
+    code_editor.moveCursor(QTextCursor.End)
+
+    code_editor.calltip_widget.hide()
+    with qtbot.waitSignal(code_editor.sig_signature_invoked,
+                          timeout=30000) as blocker:
+        qtbot.keyPress(code_editor, Qt.Key_ParenLeft, delay=1000)
+
+        # This is needed to leave time for the calltip to appear
+        # and make the tests succeed
+        qtbot.wait(2000)
+
+        args = blocker.args
+        print('args:', [args])
+        output_text = args[0]['signatures']['label']
+        assert expected_output_text in output_text
+        code_editor.calltip_widget.hide()
+
+
+@pytest.mark.slow
+@pytest.mark.second
+@pytest.mark.parametrize('params', [
+            # Parameter, Expected Output
+            ('dict', '' if PY2 else 'dict'),
+            ('type', 'type'),
+            ('import math', 'module'),
+            (TEST_TEXT, TEST_DOCSTRING)
+        ]
+    )
+def test_get_hints(qtbot, lsp_codeeditor, params):
+    """Test that the editor is returning hover hints."""
+    code_editor, _ = lsp_codeeditor
+    param, expected_output_text = params
+
+    # Set text in editor
+    code_editor.set_text(param)
+
+    # Moe cursor to end of line
+    code_editor.moveCursor(QTextCursor.End)
+
+    # Get cursor coordinates
+    x, y = code_editor.get_coordinates('cursor')
+    # The `- 5` is to put the mouse on top of the word
+    point = code_editor.calculate_real_position(QPoint(x - 5, y))
+
+    code_editor.tooltip_widget.hide()
+    with qtbot.waitSignal(code_editor.sig_display_object_info,
+                          timeout=30000) as blocker:
+        cursor = code_editor.cursorForPosition(point)
+        line, col = cursor.blockNumber(), cursor.columnNumber()
+        code_editor.request_hover(line, col)
+
+        # This is needed to leave time for the tooltip to appear
+        # and make the tests succeed
+        qtbot.wait(2000)
+
+        args = blocker.args
+        print('args:', [args])
+        output_text = args[0]
+        assert expected_output_text in output_text
+        code_editor.tooltip_widget.hide()