gh-71956: Fix IDLE Replace All searching up without wrap around (#152737)

When the search direction is "Up" and "Wrap around" is off, Replace All
replaced only the first match above the current position (and all matches
below it).  It now replaces all matches from the start of the text down to
the current position, consistently with the "Up" direction.
This commit is contained in:
Serhiy Storchaka authored and GitHub committed 2026-07-01 11:55:43 -04:00
1 parent 4e16b8d4ee
commit ef27e5b310
3 files changed
+105 -13

No files matched your search

+81 -5
View File
@@ -246,32 +246,108 @@ class ReplaceDialogTest(unittest.TestCase):
equal(text.get('1.2', '1.5'), 'was')
def test_replace_all(self):
# The default mode, forward with wrap around, replaces every
# match, both below and above the current position.
equal = self.assertEqual
text = self.text
pv = self.engine.patvar
rv = self.dialog.replvar
replace_all = self.dialog.replace_all
text.insert('insert', '\n')
text.insert('insert', text.get('1.0', 'end')*100)
pv.set('is')
rv.set('was')
text.delete('1.0', 'end')
text.insert('1.0', 'a\na\na\n')
text.mark_set('insert', '2.1')
pv.set('a')
rv.set('b')
replace_all()
self.assertNotIn('is', text.get('1.0', 'end'))
equal(text.get('1.0', '3.end'), 'b\nb\nb') # Wrapped around.
# An empty regular expression is reported as an error.
self.engine.revar.set(True)
pv.set('')
replace_all()
self.assertIn('error', showerror.title)
self.assertIn('Empty', showerror.message)
# An invalid replacement expression is reported as an error,
# and nothing is replaced.
text.delete('1.0', 'end')
text.insert('1.0', 'asT')
pv.set('[s][T]')
rv.set('\\')
replace_all()
self.assertIn('error', showerror.title)
self.assertIn('Invalid Replace Expression', showerror.message)
equal(text.get('1.0', '1.end'), 'asT')
# A pattern that is not present replaces nothing.
self.engine.revar.set(False)
text.delete('1.0', 'end')
text.insert('1.0', 'unchanged')
pv.set('text which is not present')
rv.set('foobar')
replace_all()
equal(text.get('1.0', '1.end'), 'unchanged')
def test_replace_all_backwards_no_wrap(self):
# gh-71956: 'up' without wrap replaces all matches from the start
# of the text down to the current position, not just one up.
equal = self.assertEqual
text = self.text
pv = self.engine.patvar
rv = self.dialog.replvar
replace_all = self.dialog.replace_all
self.engine.backvar.set(True)
self.engine.wrapvar.set(False)
text.delete('1.0', 'end')
text.insert('1.0', 'a\na\na\n')
text.mark_set('insert', '2.1')
pv.set('a')
rv.set('b')
replace_all()
equal(text.get('1.0', '1.end'), 'b') # Above the cursor.
equal(text.get('2.0', '2.end'), 'b') # At the cursor.
equal(text.get('3.0', '3.end'), 'a') # Below the cursor, untouched.
def test_replace_all_forwards_no_wrap(self):
# 'down' without wrap replaces all matches from the current
# position to the end of the text, and none before it.
equal = self.assertEqual
text = self.text
pv = self.engine.patvar
rv = self.dialog.replvar
replace_all = self.dialog.replace_all
self.engine.backvar.set(False)
self.engine.wrapvar.set(False)
text.delete('1.0', 'end')
text.insert('1.0', 'a\na\na\n')
text.mark_set('insert', '2.1')
pv.set('a')
rv.set('b')
replace_all()
equal(text.get('1.0', '1.end'), 'a') # Before the cursor, untouched.
equal(text.get('2.0', '2.end'), 'a') # Before the cursor, untouched.
equal(text.get('3.0', '3.end'), 'b') # After the cursor.
def test_replace_all_backwards_wrap(self):
# With wrap around, an 'up' search also replaces every match.
equal = self.assertEqual
text = self.text
pv = self.engine.patvar
rv = self.dialog.replvar
replace_all = self.dialog.replace_all
self.engine.backvar.set(True)
self.engine.wrapvar.set(True)
text.delete('1.0', 'end')
text.insert('1.0', 'a\na\na\n')
text.mark_set('insert', '2.1')
pv.set('a')
rv.set('b')
replace_all()
equal(text.get('1.0', '3.end'), 'b\nb\nb')
def test_default_command(self):
text = self.text
+21 -8
View File
@@ -122,12 +122,13 @@ class ReplaceDialog(SearchDialogBase):
def replace_all(self, event=None):
"""Handle the Replace All button.
Search text for occurrences of the Find value and replace
each of them. The 'wrap around' value controls the start
point for searching. If wrap isn't set, then the searching
starts at the first occurrence after the current selection;
if wrap is set, the replacement starts at the first line.
The replacement is always done top-to-bottom in the text.
Search text for occurrences of the Find value and replace each
of them. The 'wrap around' and direction values control which
occurrences are replaced. With wrap around, every occurrence is
replaced. Without it, a forward search replaces occurrences from
the current position to the end of the text, and a backward search
replaces occurrences from the beginning of the text to the current
position. The replacement is always done top-to-bottom.
"""
prog = self.engine.getprog()
if not prog:
@@ -142,9 +143,18 @@ class ReplaceDialog(SearchDialogBase):
text.tag_remove("hit", "1.0", "end")
line = res[0]
col = res[1].start()
# For a backward search without wrap, replace top-to-bottom from
# the start of the text down to the first match at or above the
# current position (gh-71956). A mark tracks that stop point.
stop = None
if self.engine.iswrap():
line = 1
col = 0
elif self.engine.isback():
stop = "replace_all_stop"
text.mark_set(stop, "%d.%d" % (line, res[1].end()))
line = 1
col = 0
ok = True
first = last = None
# XXX ought to replace circular instead of top-to-bottom when wrapping
@@ -152,12 +162,13 @@ class ReplaceDialog(SearchDialogBase):
while res := self.engine.search_forward(
text, prog, line, col, wrap=False, ok=ok):
line, m = res
chars = text.get("%d.0" % line, "%d.0" % (line+1))
i, j = m.span()
if stop is not None and text.compare("%d.%d" % (line, i), ">=", stop):
break
orig = m.group()
new = self._replace_expand(m, repl)
if new is None:
break
i, j = m.span()
first = "%d.%d" % (line, i)
last = "%d.%d" % (line, j)
if new == orig:
@@ -170,6 +181,8 @@ class ReplaceDialog(SearchDialogBase):
text.insert(first, new, self.insert_tags)
col = i + len(new)
ok = False
if stop is not None:
text.mark_unset(stop)
text.undo_block_stop()
if first and last:
self.show_hit(first, last)
@@ -0,0 +1,3 @@
Fix Replace All in the IDLE editor's Replace dialog when the search
direction is "Up" and "Wrap around" is off: it now replaces all matches
above the current position instead of only the first one.