Repository: spyder-ide/spyder

Issue:
Add encapsulate with parentheses (quotes, brackets, braces) function
## Encapsulating parentheses and others

I want to be able to highlight code and press the button for some beginning character like the opening parenthesis, so that the whole highlighted text is surrounded by an opening parenthesis on the left and a closing parenthesis on the right, as an example. Many editors support this kind of behavior and I have gotten used to being able to do this.

**What steps will reproduce the problem?**
1. highlight some code
2. press opening parenthesis key

**What is the expected output? What do you see instead?**
- The text should be surrounded by parenthesis.
- The text was replaced.
## Versions and main components
- Spyder Version: 2.3.9 (in an Anaconda env for Python 3.5)
- Python Version: Python 3.5
- Operating system: Fedora 22
## Dependencies

Please go to the menu entry `Help > Optional Dependencies` (or
`Help > Dependencies`), press the button `Copy to clipboard`
and paste the contents below:

IPython >=1.0      :  5.1.0 (OK)
jedi >=0.8.1;<0.9.0:  0.9.0 (NOK)
matplotlib >=1.0   :  1.5.1 (OK)
pandas >=0.13.1    :  0.18.1 (OK)
pep8 >=0.6         :  1.7.0 (OK)
psutil >=0.3       :  4.3.1 (OK)
pyflakes >=0.6.0   :  1.3.0 (OK)
pygments >=1.6     :  2.1.3 (OK)
pylint >=0.25      :  None (NOK)
qtconsole >=4.0    :  4.2.1 (OK)
rope >=0.9.2       :  0.9.4-1 (OK)
sphinx >=0.6.6     :  1.4.1 (OK)
sympy >=0.7.3      :  None (NOK)
zmq >=2.1.11       :  15.4.0 (OK)



Discussion:
Good idea. It shouldn't be that hard to implement :-)

If I may add something to this request, also enclose selection with brackets, curlies, and quotes, when the corresponding key is pressed. 


Would like to implement this if someone points me to the right direction in the code.
I would start by studying https://github.com/spyder-ide/spyder/blob/da167e4e088373bcb3028112027c3eddab34c14f/spyder/widgets/sourcecode/codeeditor.py#L2668
That block automatically inserts the closing parenthesis when you type the opening parenthesis in `foo(`.

Good luck!
@h4gen Spyder already has quote insertion for selected text in master.  If you select text and press the `'` or `"` key the selection will get quoted.

This functionality is now implemented as the first example of an editor extension:

https://github.com/spyder-ide/spyder/blob/master/spyder/widgets/sourcecode/extensions/closequotes.py

This section does the part you want to do with brackets:

https://github.com/spyder-ide/spyder/blob/da167e4e088373bcb3028112027c3eddab34c14f/spyder/widgets/sourcecode/extensions/closequotes.py#L71-L78

I think you will want to write an editor extension similar to closequotes extension. See this PR for more info.

https://github.com/spyder-ide/spyder/pull/5002

Sounds like a fun project!

Resolving patch:
diff --git a/spyder/plugins/editor/extensions/closebrackets.py b/spyder/plugins/editor/extensions/closebrackets.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/editor/extensions/closebrackets.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see spyder/__init__.py for details)
+"""This module contains the close quotes editor extension."""
+
+from qtpy.QtCore import Qt
+from qtpy.QtGui import QTextCursor
+
+from spyder.api.editorextension import EditorExtension
+
+
+class CloseBracketsExtension(EditorExtension):
+    """Editor Extension for insert brackets automatically."""
+    BRACKETS_LIST = ["(", ")", "{", "}", "[", "]"]
+    BRACKETS_KEYS = [Qt.Key_ParenLeft, Qt.Key_ParenRight,
+                     Qt.Key_BraceLeft, Qt.Key_BraceRight,
+                     Qt.Key_BracketLeft, Qt.Key_BracketRight]
+    BRACKETS_CHAR = dict(zip(BRACKETS_KEYS, BRACKETS_LIST))
+    BRACKETS_LEFT = dict(zip(BRACKETS_KEYS[::2], BRACKETS_LIST[::2]))
+    BRACKETS_RIGHT = dict(zip(BRACKETS_KEYS[1::2], BRACKETS_LIST[1::2]))
+    BRACKETS_PAIR = {Qt.Key_ParenLeft: "()", Qt.Key_ParenRight: "()",
+                     Qt.Key_BraceLeft: "{}", Qt.Key_BraceRight: "{}",
+                     Qt.Key_BracketLeft: "[]", Qt.Key_BracketRight: "[]"}
+
+    def on_state_changed(self, state):
+        """Connect/disconnect sig_key_pressed signal."""
+        if state:
+            self.editor.sig_key_pressed.connect(self._on_key_pressed)
+        else:
+            self.editor.sig_key_pressed.disconnect(self._on_key_pressed)
+
+    def _on_key_pressed(self, event):
+        if event.isAccepted():
+            return
+
+        key = event.key()
+        if key in self.BRACKETS_CHAR and self.enabled:
+            self._autoinsert_brackets(key)
+            event.accept()
+
+    def unmatched_brackets_in_line(self, text, closing_brackets_type=None):
+        """
+        Checks if there is an unmatched brackets in the 'text'.
+
+        The brackets type can be general or specified by closing_brackets_type
+        (')', ']' or '}')
+        """
+        if closing_brackets_type is None:
+            opening_brackets = self.BRACKETS_LEFT.values()
+            closing_brackets = self.BRACKETS_RIGHT.values()
+        else:
+            closing_brackets = [closing_brackets_type]
+            opening_brackets = [{')': '(', '}': '{',
+                                 ']': '['}[closing_brackets_type]]
+        block = self.editor.textCursor().block()
+        line_pos = block.position()
+        for pos, char in enumerate(text):
+            if char in opening_brackets:
+                match = self.editor.find_brace_match(line_pos+pos, char,
+                                                     forward=True)
+                if (match is None) or (match > line_pos+len(text)):
+                    return True
+            if char in closing_brackets:
+                match = self.editor.find_brace_match(line_pos+pos, char,
+                                                     forward=False)
+                if (match is None) or (match < line_pos):
+                    return True
+        return False
+
+    def _autoinsert_brackets(self, key):
+        """Control automatic insertation of brackets in various situations."""
+        char = self.BRACKETS_CHAR[key]
+        pair = self.BRACKETS_PAIR[key]
+
+        line_text = self.editor.get_text('sol', 'eol')
+        line_to_cursor = self.editor.get_text('sol', 'cursor')
+        cursor = self.editor.textCursor()
+        trailing_text = self.editor.get_text('cursor', 'eol').strip()
+
+        if self.editor.has_selected_text():
+            text = self.editor.get_selected_text()
+            self.editor.insert_text("{0}{1}{2}".format(pair[0], text, pair[1]))
+            # Keep text selected, for inserting multiple brackets
+            cursor.movePosition(QTextCursor.Left, QTextCursor.MoveAnchor, 1)
+            cursor.movePosition(QTextCursor.Left, QTextCursor.KeepAnchor,
+                                len(text))
+            self.editor.setTextCursor(cursor)
+        elif key in self.BRACKETS_LEFT:
+            if (not trailing_text or
+                    trailing_text[0] in self.BRACKETS_RIGHT.values() or
+                    trailing_text[0] in [',', ':', ';']):
+                # Automatic insertion of brackets
+                self.editor.insert_text(pair)
+                cursor.movePosition(QTextCursor.PreviousCharacter)
+                self.editor.setTextCursor(cursor)
+            else:
+                self.editor.insert_text(char)
+            if char in self.editor.signature_completion_characters:
+                self.editor.request_signature()
+        elif key in self.BRACKETS_RIGHT:
+            if (self.editor.next_char() == char and
+                    not self.editor.textCursor().atBlockEnd() and
+                    not self.unmatched_brackets_in_line(
+                        cursor.block().text(), char)):
+                # Overwrite an existing brackets if all in line are matched
+                cursor.movePosition(QTextCursor.NextCharacter,
+                                    QTextCursor.KeepAnchor, 1)
+                cursor.clearSelection()
+                self.editor.setTextCursor(cursor)
+            else:
+                self.editor.insert_text(char)
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
@@ -71,6 +71,8 @@
     EditorExtensionsManager)
 from spyder.plugins.editor.extensions.closequotes import (
     CloseQuotesExtension)
+from spyder.plugins.editor.extensions.closebrackets import (
+    CloseBracketsExtension)
 from spyder.plugins.editor.api.decoration import TextDecoration
 from spyder.plugins.editor.utils.lsp import (
     request, handles, class_register)
@@ -501,6 +503,7 @@ def __init__(self, parent=None):
         self.editor_extensions = EditorExtensionsManager(self)
 
         self.editor_extensions.add(CloseQuotesExtension())
+        self.editor_extensions.add(CloseBracketsExtension())
 
     # ---- Keyboard Shortcuts
 
@@ -954,6 +957,9 @@ def set_go_to_definition_enabled(self, enable):
     def set_close_parentheses_enabled(self, enable):
         """Enable/disable automatic parentheses insertion feature"""
         self.close_parentheses_enabled = enable
+        bracket_extension = self.editor_extensions.get(CloseBracketsExtension)
+        if bracket_extension is not None:
+            bracket_extension.enabled = enable
 
     def set_close_quotes_enabled(self, enable):
         """Enable/disable automatic quote insertion feature"""
@@ -2633,32 +2639,6 @@ def __forbidden_colon_end_char(self, text):
         else:
             return False
 
-    def __unmatched_braces_in_line(self, text, closing_braces_type=None):
-        """
-        Checks if there is an unmatched brace in the 'text'.
-        The brace type can be general or specified by closing_braces_type
-        (')', ']', or '}')
-        """
-        if closing_braces_type is None:
-            opening_braces = ['(', '[', '{']
-            closing_braces = [')', ']', '}']
-        else:
-            closing_braces = [closing_braces_type]
-            opening_braces = [{')': '(', '}': '{',
-                               ']': '['}[closing_braces_type]]
-        block = self.textCursor().block()
-        line_pos = block.position()
-        for pos, char in enumerate(text):
-            if char in opening_braces:
-                match = self.find_brace_match(line_pos+pos, char, forward=True)
-                if (match is None) or (match > line_pos+len(text)):
-                    return True
-            if char in closing_braces:
-                match = self.find_brace_match(line_pos+pos, char, forward=False)
-                if (match is None) or (match < line_pos):
-                    return True
-        return False
-
     def __has_colon_not_in_brackets(self, text):
         """
         Return whether a string has a colon which is not between brackets.
@@ -2666,13 +2646,16 @@ def __has_colon_not_in_brackets(self, text):
         not between a pair of (round, square or curly) brackets. It assumes
         that the brackets in the string are balanced.
         """
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
         for pos, char in enumerate(text):
-            if char == ':' and not self.__unmatched_braces_in_line(text[:pos]):
+            if (char == ':' and
+                    not bracket_ext.unmatched_brackets_in_line(text[:pos])):
                 return True
         return False
 
     def autoinsert_colons(self):
         """Decide if we want to autoinsert colons"""
+        bracket_ext = self.editor_extensions.get(CloseBracketsExtension)
         line_text = self.get_text('sol', 'cursor')
         if not self.textCursor().atBlockEnd():
             return False
@@ -2682,7 +2665,7 @@ def autoinsert_colons(self):
             return False
         elif self.__forbidden_colon_end_char(line_text):
             return False
-        elif self.__unmatched_braces_in_line(line_text):
+        elif bracket_ext.unmatched_brackets_in_line(line_text):
             return False
         elif self.__has_colon_not_in_brackets(line_text):
             return False
@@ -2925,39 +2908,6 @@ def keyPressEvent(self, event):
                 not self.has_selected_text()):
             self.insert_text(text)
             self.request_signature()
-        elif (text == '(' and
-              not self.has_selected_text()):
-            self.handle_parentheses(text)
-        elif (text in ('[', '{') and not has_selection and
-              self.close_parentheses_enabled):
-            s_trailing_text = self.get_text('cursor', 'eol').strip()
-            if len(s_trailing_text) == 0 or \
-               s_trailing_text[0] in (',', ')', ']', '}'):
-                self.insert_text({'{': '{}', '[': '[]'}[text])
-                cursor = self.textCursor()
-                cursor.movePosition(QTextCursor.PreviousCharacter)
-                self.setTextCursor(cursor)
-            else:
-                TextEditBaseWidget.keyPressEvent(self, event)
-        elif key in (Qt.Key_ParenRight, Qt.Key_BraceRight, Qt.Key_BracketRight)\
-          and not has_selection and self.close_parentheses_enabled \
-          and not self.textCursor().atBlockEnd():
-            cursor = self.textCursor()
-            cursor.movePosition(QTextCursor.NextCharacter,
-                                QTextCursor.KeepAnchor)
-            text = to_text_string(cursor.selectedText())
-            key_matches_next_char = (
-                text == {Qt.Key_ParenRight: ')', Qt.Key_BraceRight: '}',
-                         Qt.Key_BracketRight: ']'}[key]
-            )
-            if (key_matches_next_char
-                    and not self.__unmatched_braces_in_line(
-                        cursor.block().text(), text)):
-                # overwrite an existing brace if all braces in line are matched
-                cursor.clearSelection()
-                self.setTextCursor(cursor)
-            else:
-                TextEditBaseWidget.keyPressEvent(self, event)
         elif key == Qt.Key_Colon and not has_selection \
              and self.auto_unindent_enabled:
             leading_text = self.get_text('sol', 'cursor')
@@ -3013,21 +2963,6 @@ def run_pygments_highlighter(self):
         if isinstance(self.highlighter, sh.PygmentsSH):
             self.highlighter.make_charlist()
 
-    def handle_parentheses(self, text):
-        """Handle left and right parenthesis depending on editor config."""
-        # position = self.get_position('cursor')
-        rest = self.get_text('cursor', 'eol').rstrip()
-        valid = not rest or rest[0] in (',', ')', ']', '}')
-        if self.close_parentheses_enabled and valid:
-            self.insert_text('()')
-            cursor = self.textCursor()
-            cursor.movePosition(QTextCursor.PreviousCharacter)
-            self.setTextCursor(cursor)
-        else:
-            self.insert_text(text)
-        if '(' in self.signature_completion_characters:
-            self.request_signature()
-
     def mouseMoveEvent(self, event):
         """Underline words when pressing <CONTROL>"""
         self.mouse_point = event.pos()


Test patch:
diff --git a/spyder/plugins/editor/extensions/tests/test_closebrackets.py b/spyder/plugins/editor/extensions/tests/test_closebrackets.py
new file mode 100644
--- /dev/null
+++ b/spyder/plugins/editor/extensions/tests/test_closebrackets.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+"""Tests for close brackets."""
+
+# Third party imports
+import pytest
+from qtpy.QtGui import QTextCursor
+
+# Local imports
+from spyder.utils.qthelpers import qapplication
+from spyder.plugins.editor.widgets.codeeditor import CodeEditor
+from spyder.plugins.editor.utils.editor import TextHelper
+from spyder.plugins.editor.extensions.closebrackets import (
+        CloseBracketsExtension)
+
+
+# =============================================================================
+# ---- Fixtures
+# =============================================================================
+@pytest.fixture
+def editor_close_brackets():
+    """Set up Editor with close brackets activated."""
+    app = qapplication()
+    editor = CodeEditor(parent=None)
+    kwargs = {}
+    kwargs['language'] = 'Python'
+    kwargs['close_parentheses'] = True
+    editor.setup_editor(**kwargs)
+    return editor
+
+
+# =============================================================================
+# ---- Tests
+# =============================================================================
+@pytest.mark.parametrize(
+    'text, expected_text, cursor_column',
+    [
+        ("(", "()", 1),  # Close brackets
+        ("{", "{}", 1),
+        ("[", "[]", 1),
+    ])
+def test_close_brackets(qtbot, editor_close_brackets, text, expected_text,
+                        cursor_column):
+    """Test insertion of brackets."""
+    editor = editor_close_brackets
+
+    qtbot.keyClicks(editor, text)
+    assert editor.toPlainText() == expected_text
+
+    assert cursor_column == TextHelper(editor).current_column_nbr()
+
+
+@pytest.mark.parametrize(
+    'text, expected_text, cursor_column',
+    [
+        ('()', '(())', 2),  # Complete in brackets
+        ('{}', '{()}', 2),
+        ('[]', '[()]', 2),
+        (',', '(),', 1),  # Complete before commas, colons and semi-colons
+        (':', '():', 1),
+        (';', '();', 1),
+    ])
+def test_nested_brackets(qtbot, editor_close_brackets, text, expected_text,
+                      cursor_column):
+    """
+    Test completion of brackets inside brackets and before commas,
+    colons and semi-colons.
+    """
+    editor = editor_close_brackets
+
+    qtbot.keyClicks(editor, text)
+    editor.move_cursor(-1)
+    qtbot.keyClicks(editor, '(')
+    assert editor.toPlainText() == expected_text
+
+    assert cursor_column == TextHelper(editor).current_column_nbr()
+
+
+def test_selected_text(qtbot, editor_close_brackets):
+    """Test insert surronding brackets to selected text."""
+    editor = editor_close_brackets
+    editor.set_text("some text")
+
+    # select some
+    cursor = editor.textCursor()
+    cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 4)
+    editor.setTextCursor(cursor)
+
+    qtbot.keyClicks(editor, "(")
+    assert editor.toPlainText() == "(some) text"
+
+    qtbot.keyClicks(editor, "}")
+    assert editor.toPlainText() == "({some}) text"
+
+    qtbot.keyClicks(editor, "[")
+    assert editor.toPlainText() == "({[some]}) text"
+
+
+def test_selected_text_multiple_lines(qtbot, editor_close_brackets):
+    """Test insert surronding brackets to multiple lines selected text."""
+    editor = editor_close_brackets
+    text = ("some text\n"
+            "\n"
+            "some text")
+    editor.set_text(text)
+
+    # select until second some
+    cursor = editor.textCursor()
+    cursor.movePosition(QTextCursor.Right, QTextCursor.KeepAnchor, 4)
+    cursor.movePosition(QTextCursor.Down, QTextCursor.KeepAnchor, 2)
+    editor.setTextCursor(cursor)
+
+    qtbot.keyClicks(editor, ")")
+    assert editor.toPlainText() == ("(some text\n"
+                                    "\n"
+                                    "some) text")
+
+    qtbot.keyClicks(editor, "{")
+    assert editor.toPlainText() == ("({some text\n"
+                                    "\n"
+                                    "some}) text")
+
+    qtbot.keyClicks(editor, "]")
+    assert editor.toPlainText() == ("({[some text\n"
+                                    "\n"
+                                    "some]}) text")
+
+
+def test_complex_completion(qtbot, editor_close_brackets):
+    """Test bracket completion in nested brackets."""
+    editor = editor_close_brackets
+    # Test completion when following character is a right bracket
+    editor.textCursor().insertText('foo(bar)')
+    editor.move_cursor(-1)
+    qtbot.keyClicks(editor, '(')
+    assert editor.toPlainText() == 'foo(bar())'
+    assert editor.textCursor().columnNumber() == 8
+    # Test normal insertion when next character is not a right bracket
+    editor.move_cursor(-1)
+    qtbot.keyClicks(editor, '[')
+    assert editor.toPlainText() == 'foo(bar[())'
+    assert editor.textCursor().columnNumber() == 8
+    # Test completion when following character is a comma
+    qtbot.keyClicks(editor, ',')
+    editor.move_cursor(-1)
+    qtbot.keyClicks(editor, '{')
+    assert editor.toPlainText() == 'foo(bar[{},())'
+    assert editor.textCursor().columnNumber() == 9
+
+
+def test_bracket_closing(qtbot, editor_close_brackets):
+    """Test bracket completion with existing brackets."""
+    editor = editor_close_brackets
+    editor.textCursor().insertText('foo(bar(x')
+    qtbot.keyClicks(editor, ')')
+    assert editor.toPlainText() == 'foo(bar(x)'
+    assert editor.textCursor().columnNumber() == 10
+    qtbot.keyClicks(editor, ')')
+    assert editor.toPlainText() == 'foo(bar(x))'
+    assert editor.textCursor().columnNumber() == 11
+    # same ')' closing with existing brackets starting at 'foo(bar(x|))'
+    editor.move_cursor(-2)
+    qtbot.keyClicks(editor, ')')
+    assert editor.toPlainText() == 'foo(bar(x))'
+    assert editor.textCursor().columnNumber() == 10
+    qtbot.keyClicks(editor, ')')
+    assert editor.toPlainText() == 'foo(bar(x))'
+    assert editor.textCursor().columnNumber() == 11
+
+
+def test_activate_deactivate(qtbot, editor_close_brackets):
+    """Test activating/desctivating close quotes editor extension."""
+    editor = editor_close_brackets
+    bracket_extension = editor.editor_extensions.get(CloseBracketsExtension)
+
+    qtbot.keyClicks(editor, "(")
+    assert editor.toPlainText() == "()"
+
+    editor.set_text("")
+    bracket_extension.enabled = False
+    qtbot.keyClicks(editor, "(")
+    assert editor.toPlainText() == "("
+
+    editor.set_text("")
+    bracket_extension.enabled = True
+    qtbot.keyClicks(editor, "(")
+    assert editor.toPlainText() == "()"
+
+
+if __name__ == '__main__':
+    pytest.main()
diff --git a/spyder/plugins/editor/widgets/tests/test_codeeditor.py b/spyder/plugins/editor/widgets/tests/test_codeeditor.py
--- a/spyder/plugins/editor/widgets/tests/test_codeeditor.py
+++ b/spyder/plugins/editor/widgets/tests/test_codeeditor.py
@@ -56,37 +56,3 @@ def test_editor_lower_to_upper(editorbot):
     widget.transform_to_uppercase()
     new_text = widget.get_text('sof', 'eof')
     assert text != new_text
-
-def test_editor_complete_backet(editorbot):
-    qtbot, editor = editorbot
-    editor.textCursor().insertText('foo')
-    qtbot.keyClicks(editor, '(')
-    assert editor.toPlainText() == 'foo()'
-    assert editor.textCursor().columnNumber() == 4
-
-def test_editor_complete_bracket_nested(editorbot):
-    qtbot, editor = editorbot
-    editor.textCursor().insertText('foo(bar)')
-    editor.move_cursor(-1)
-    qtbot.keyClicks(editor, '(')
-    assert editor.toPlainText() == 'foo(bar())'
-    assert editor.textCursor().columnNumber() == 8
-
-def test_editor_bracket_closing(editorbot):
-    qtbot, editor = editorbot
-    editor.textCursor().insertText('foo(bar(x')
-    qtbot.keyClicks(editor, ')')
-    assert editor.toPlainText() == 'foo(bar(x)'
-    assert editor.textCursor().columnNumber() == 10
-    qtbot.keyClicks(editor, ')')
-    assert editor.toPlainText() == 'foo(bar(x))'
-    assert editor.textCursor().columnNumber() == 11
-    # same ')' closing with existing brackets starting at 'foo(bar(x|))'
-    editor.move_cursor(-2)
-    qtbot.keyClicks(editor, ')')
-    assert editor.toPlainText() == 'foo(bar(x))'
-    assert editor.textCursor().columnNumber() == 10
-    qtbot.keyClicks(editor, ')')
-    assert editor.toPlainText() == 'foo(bar(x))'
-    assert editor.textCursor().columnNumber() == 11
-