Repository: spyder-ide/spyder

Issue:
Error when replacing "\" by "\\"
## Description

**What steps will reproduce the problem?**

1.  Tried to replace all "\" by "\\"
2.  Crash 
3. my code : 
" 
import pandas as pd 

a = pd.read_csv("C:\\Users\badia\Desktop\PSB\s1\Python\Exercices\data.csv")
"

**What is the expected output? What do you see instead?**


**Please provide any additional information below**

  File "C:\Users\badia\Anaconda3\lib\site-packages\spyder\widgets\findreplace.py", line 504, in replace_find_all
    self.replace_find(focus_replace_text, replace_all=True)
  File "C:\Users\badia\Anaconda3\lib\site-packages\spyder\widgets\findreplace.py", line 449, in replace_find
    if len(re.findall(pattern, cmptxt2)) > 0:
  File "C:\Users\badia\Anaconda3\lib\re.py", line 222, in findall
    return _compile(pattern, flags).findall(string)
  File "C:\Users\badia\Anaconda3\lib\re.py", line 301, in _compile
    p = sre_compile.compile(pattern, flags)
  File "C:\Users\badia\Anaconda3\lib\sre_compile.py", line 562, in compile
    p = sre_parse.parse(p, flags)
  File "C:\Users\badia\Anaconda3\lib\sre_parse.py", line 847, in parse
    source = Tokenizer(str)
  File "C:\Users\badia\Anaconda3\lib\sre_parse.py", line 231, in __init__
    self.__next()
  File "C:\Users\badia\Anaconda3\lib\sre_parse.py", line 245, in __next
    self.string, len(self.string) - 1) from None
sre_constants.error: bad escape (end of pattern) at position 0


## Version and main components

* Spyder Version: 3.2.3 
* Python Version: 3.6.2
* Qt Versions: 5.6.2, PyQt5 5.6 on Win


Discussion:
Thanks for reporting. We'll fix this problem is a future release.
Note: This happens if the user has activated regular expressions. `\` is of course not a valid expression to search for.

One part of the problem is that the user was probably not aware of this fact. This is addressed by the above PR [#5614].

The second aspect is that acually performing the search on an invalid regexp raises the above error. One can debate how to handle this. In the simplest case, one could catch `re.error` for the call of `re.findall()` in `FindReplace.replace_find()`. Alternatively, one could deactivate the replace buttons for invalid regular expressions. Personally, I'd think that the former solution is enough if the user has already feedback through PR [#5614].

@timhoffm, I agree that catching `re.error` is the best that we can do. Could you implement that in another PR, please?

Resolving patch:
diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py
--- a/spyder/widgets/findreplace.py
+++ b/spyder/widgets/findreplace.py
@@ -426,7 +426,12 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
         if (self.editor is not None):
             replace_text = to_text_string(self.replace_text.currentText())
             search_text = to_text_string(self.search_text.currentText())
-            pattern = search_text if self.re_button.isChecked() else None
+            re_pattern = None
+            if self.re_button.isChecked():
+                try:
+                    re_pattern = re.compile(search_text)
+                except re.error:
+                    return  # do nothing with an invalid regexp
             case = self.case_button.isChecked()
             first = True
             cursor = None
@@ -436,7 +441,7 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
                     seltxt = to_text_string(self.editor.get_selected_text())
                     cmptxt1 = search_text if case else search_text.lower()
                     cmptxt2 = seltxt if case else seltxt.lower()
-                    if not pattern:
+                    if re_pattern is None:
                         has_selected = self.editor.has_selected_text()
                         if has_selected and cmptxt1 == cmptxt2:
                             # Text was already found, do nothing
@@ -446,7 +451,7 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
                                              rehighlight=False):
                                 break
                     else:
-                        if len(re.findall(pattern, cmptxt2)) > 0:
+                        if len(re_pattern.findall(cmptxt2)) > 0:
                             pass
                         else:
                             if not self.find(changed=False, forward=True,
@@ -476,13 +481,13 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
                         # Avoid infinite loop: single found occurrence
                         break
                     position0 = position1
-                if pattern is None:
+                if re_pattern is None:
                     cursor.removeSelectedText()
                     cursor.insertText(replace_text)
                 else:
                     seltxt = to_text_string(cursor.selectedText())
                     cursor.removeSelectedText()
-                    cursor.insertText(re.sub(pattern, replace_text, seltxt))
+                    cursor.insertText(re_pattern.sub(replace_text, seltxt))
                 if self.find_next():
                     found_cursor = self.editor.textCursor()
                     cursor.setPosition(found_cursor.selectionStart(),
@@ -507,39 +512,38 @@ def replace_find_all(self, focus_replace_text=False):
     @Slot()
     def replace_find_selection(self, focus_replace_text=False):
         """Replace and find in the current selection"""
-        if (self.editor is not None):
+        if self.editor is not None:
             replace_text = to_text_string(self.replace_text.currentText())
             search_text = to_text_string(self.search_text.currentText())
-            pattern = search_text if self.re_button.isChecked() else None
             case = self.case_button.isChecked()
             words = self.words_button.isChecked()
             re_flags = re.MULTILINE if case else re.IGNORECASE|re.MULTILINE
 
-            cursor = self.editor.textCursor()
-            cursor.beginEditBlock()
-            seltxt = to_text_string(self.editor.get_selected_text())
-            if not pattern:
+            re_pattern = None
+            if self.re_button.isChecked():
+                pattern = search_text
+            else:
                 pattern = re.escape(search_text)
                 replace_text = re.escape(replace_text)
-            if words:
-                #If whole words is checked we need to check that each match
-                #is actually a whole word before replacing
-                try:
-                    re.compile(pattern)
-                except re.error:
-                    return #if the pattern won't compile cancel the find/replace
-                word_pattern = r'\b{pattern}\b'.format(pattern = pattern)
-                replacement = re.sub(word_pattern, replace_text, seltxt, flags=re_flags)
-            else:
-                replacement = re.sub(pattern, replace_text, seltxt, flags=re_flags)
-            if replacement != seltxt:
+            if words:  # match whole words only
+                pattern = r'\b{pattern}\b'.format(pattern=pattern)
+            try:
+                re_pattern = re.compile(pattern, flags=re_flags)
+            except re.error as e:
+                return  # do nothing with an invalid regexp
+
+            selected_text = to_text_string(self.editor.get_selected_text())
+            replacement = re_pattern.sub(replace_text, selected_text)
+            if replacement != selected_text:
+                cursor = self.editor.textCursor()
+                cursor.beginEditBlock()
                 cursor.removeSelectedText()
                 for plain_char in CONTROL_CHARACTERS:
                     replacement = replacement.replace(
                         plain_char, CONTROL_CHARACTERS[plain_char])
                 replacement = re.sub(r'\\(.)', r'\1', replacement)
                 cursor.insertText(replacement)
-            cursor.endEditBlock()
+                cursor.endEditBlock()
             if focus_replace_text:
                 self.replace_text.setFocus()
             else:


Test patch:
diff --git a/spyder/widgets/tests/test_editor.py b/spyder/widgets/tests/test_editor.py
--- a/spyder/widgets/tests/test_editor.py
+++ b/spyder/widgets/tests/test_editor.py
@@ -320,7 +320,6 @@ def test_replace_enter_press(editor_find_replace_bot):
     qtbot.keyPress(finder.search_text, Qt.Key_Return, modifier=Qt.ShiftModifier)
     assert editor.get_cursor_line_column() == (3,4)
 
-
 def test_selection_replace_plain_regex(editor_find_replace_bot):
     """Test that regex reserved characters are displayed as plain text."""
     editor_stack, editor, finder, qtbot = editor_find_replace_bot
@@ -336,6 +335,23 @@ def test_selection_replace_plain_regex(editor_find_replace_bot):
     text = editor.toPlainText()[0:-1]
     assert editor.toPlainText()[0:-1] == expected_new_text
 
+def test_replace_invalid_regex(editor_find_replace_bot):
+    """Assert that replacing an invalid regexp does nothing."""
+    editor_stack, editor, finder, qtbot = editor_find_replace_bot
+    old_text = editor.toPlainText()
+    finder.show()
+    finder.show_replace()
+    qtbot.keyClicks(finder.search_text, '\\')
+    qtbot.keyClicks(finder.replace_text, 'anything')
+    if not finder.re_button.isChecked():
+        qtbot.mouseClick(finder.re_button, Qt.LeftButton)
+    qtbot.mouseClick(finder.replace_button, Qt.LeftButton)
+    assert editor.toPlainText() == old_text
+    qtbot.mouseClick(finder.replace_sel_button, Qt.LeftButton)
+    assert editor.toPlainText() == old_text
+    qtbot.mouseClick(finder.replace_all_button, Qt.LeftButton)
+    assert editor.toPlainText() == old_text
+
 def test_advance_cell(editor_cells_bot):
     editor_stack, editor, qtbot = editor_cells_bot