Repository: spyder-ide/spyder

Issue:
Multiline search and replace doesn't replace
## Description

**What steps will reproduce the problem?**
1. Activate search & replace widget
2. With regular expression mode engaged, search for a multiline pattern (e.g. \n\n\n)
3. Enter some text to replace the expression, hit replace/find button.

**What is the expected output? What do you see instead?**

You would expect the multiline search pattern to be replaced with the replacement text. Instead, no replacement is made.

**Please provide any additional information below**
## Version and main components
- Spyder Version: 2.3.6 
- Python Version: 2.7.9
- Qt Versions:  4.8.6, PyQt4 (API v2) 4.10.4 on Windows
## Optional dependencies

```
pyflakes >=0.5.0:  0.9.2 (OK)
pep8 >=0.6      :  1.6.2 (OK)
pygments >=1.6  :  2.0.2 (OK)
IPython >=1.0   :  4.0.0 (OK)
zmq >=2.1.11    :  14.7.0 (OK)
qtconsole >=4.0 :  4.0.1 (OK)
pandas >=0.13.1 :  0.16.2 (OK)
sphinx >=0.6.6  :  1.3.1 (OK)
rope >=0.9.2    :  0.9.4 (OK)
jedi >=0.8.1    :  0.9.0 (OK)
matplotlib >=1.0:  1.4.3 (OK)
sympy >=0.7.3   :  0.7.6 (OK)
pylint >=0.25   :  None (NOK)

```



Discussion:
We'll take a look about this one after releasing 3.0 because it's not too critical :-)

Indeed this is not working for `replace` or for `replace all`

<img width="702" alt="Screen Shot 2019-11-27 at 21 38 28" src="https://user-images.githubusercontent.com/3627835/69772568-46c2f000-115e-11ea-8656-3c6407185695.png">


Resolving patch:
diff --git a/spyder/utils/sourcecode.py b/spyder/utils/sourcecode.py
--- a/spyder/utils/sourcecode.py
+++ b/spyder/utils/sourcecode.py
@@ -23,10 +23,25 @@
 
 
 def get_eol_chars(text):
-    """Get text EOL characters"""
+    """
+    Get text EOL characters.
+
+    If None is found, return the eol based on the operatin system.
+    """
     for eol_chars, _os_name in EOL_CHARS:
         if text.find(eol_chars) > -1:
-            return eol_chars
+            break
+    else:
+        if os.name == 'nt':
+            eol_chars = "\r\n"
+        elif sys.platform.startswith('linux'):
+            eol_chars = "\n"
+        elif sys.platform == 'darwin':
+            eol_chars = "\r"
+        else:
+            eol_chars = "\n"
+
+    return eol_chars
 
 
 def get_os_name_from_eol_chars(eol_chars):
diff --git a/spyder/widgets/findreplace.py b/spyder/widgets/findreplace.py
--- a/spyder/widgets/findreplace.py
+++ b/spyder/widgets/findreplace.py
@@ -28,6 +28,7 @@
 from spyder.utils.misc import regexp_error_msg
 from spyder.plugins.editor.utils.editor import TextHelper
 from spyder.utils.qthelpers import create_toolbutton, get_icon
+from spyder.utils.sourcecode import get_eol_chars
 from spyder.widgets.comboboxes import PatternComboBox
 
 
@@ -470,7 +471,7 @@ def find(self, changed=True, forward=True, rehighlight=True,
 
     @Slot()
     def replace_find(self, focus_replace_text=False, replace_all=False):
-        """Replace and find"""
+        """Replace and find."""
         if self.editor is None:
             return
         replace_text = to_text_string(self.replace_text.currentText())
@@ -478,6 +479,7 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
         re_pattern = None
         case = self.case_button.isChecked()
         re_flags = re.MULTILINE if case else re.IGNORECASE | re.MULTILINE
+
         # Check regexp before proceeding
         if self.re_button.isChecked():
             try:
@@ -527,23 +529,35 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
                     # Identify wrapping even when the replace string
                     # includes part of the search string
                     wrapped = True
+
                 if wrapped:
-                    if position1 == position or \
-                       is_position_sup(position1, position):
+                    if (position1 == position
+                            or is_position_sup(position1, position)):
                         # Avoid infinite loop: replace string includes
                         # part of the search string
                         break
+
                 if position1 == position0:
                     # Avoid infinite loop: single found occurrence
                     break
                 position0 = position1
+
             if re_pattern is None:
                 cursor.removeSelectedText()
                 cursor.insertText(replace_text)
             else:
                 seltxt = to_text_string(cursor.selectedText())
+
+                # Note: If the selection obtained from an editor spans a line
+                # break, the text will contain a Unicode U+2029 paragraph
+                # separator character instead of a newline \n character.
+                # See: spyder-ide/spyder#2675
+                eol_char = get_eol_chars(self.editor.toPlainText())
+                seltxt = seltxt.replace(u'\u2029', eol_char)
+
                 cursor.removeSelectedText()
                 cursor.insertText(re_pattern.sub(replace_text, seltxt))
+
             if self.find_next(set_focus=False):
                 found_cursor = self.editor.textCursor()
                 cursor.setPosition(found_cursor.selectionStart(),
@@ -552,10 +566,13 @@ def replace_find(self, focus_replace_text=False, replace_all=False):
                                    QTextCursor.KeepAnchor)
             else:
                 break
+
             if not replace_all:
                 break
+
         if cursor is not None:
             cursor.endEditBlock()
+
         if focus_replace_text:
             self.replace_text.setFocus()
         else:


Test patch:
diff --git a/requirements/tests.txt b/requirements/tests.txt
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -17,3 +17,4 @@ pillow
 matplotlib
 cython
 flaky
+coverage <5.0
diff --git a/spyder/widgets/tests/test_findreplace.py b/spyder/widgets/tests/test_findreplace.py
new file mode 100644
--- /dev/null
+++ b/spyder/widgets/tests/test_findreplace.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+"""
+Tests for pathmanager.py
+"""
+# Standard library imports
+import sys
+import os
+
+# Test library imports
+import pytest
+from qtpy.QtCore import Qt, QTimer
+from qtpy.QtWidgets import QMessageBox, QPushButton
+
+# Local imports
+from spyder.plugins.editor.widgets.codeeditor import CodeEditor
+from spyder.widgets.findreplace import FindReplace
+
+
+@pytest.fixture
+def findreplace_editor(qtbot, request):
+    """Set up PathManager."""
+    editor = CodeEditor()
+    editor.setup_editor()
+    widget = FindReplace(None)
+    widget.set_editor(editor)
+    qtbot.addWidget(widget)
+    qtbot.addWidget(editor)
+    return widget, editor
+
+
+def test_findreplace_multiline_replacement(findreplace_editor, qtbot):
+    """
+    Test find replace widget for multiline regex replacements
+    See: spyder-ide/spyder#2675
+    """
+    expected = '\n\nhello world!\n\n'
+    findreplace, editor = findreplace_editor
+    editor.set_text('\n\nhello\n\n\nworld!\n\n')
+    editor.show()
+
+    findreplace.show()
+    findreplace.show_replace()
+
+    findreplace.re_button.setChecked(True)
+    edit = findreplace.search_text.lineEdit()
+    edit.clear()
+    edit.setText('\\n\\n\\n')
+    findreplace.replace_text.setCurrentText(' ')
+    qtbot.wait(1000)
+    findreplace.replace_find_all()
+    qtbot.wait(1000)
+    assert editor.toPlainText() == expected
+
+
+if __name__ == "__main__":
+    pytest.main([os.path.basename(__file__)])