Skip to content

Commit ef27e5b

Browse files
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.
1 parent 4e16b8d commit ef27e5b

3 files changed

Lines changed: 105 additions & 13 deletions

File tree

Lib/idlelib/idle_test/test_replace.py

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -246,32 +246,108 @@ def test_replace_backwards(self):
246246
equal(text.get('1.2', '1.5'), 'was')
247247

248248
def test_replace_all(self):
249+
# The default mode, forward with wrap around, replaces every
250+
# match, both below and above the current position.
251+
equal = self.assertEqual
249252
text = self.text
250253
pv = self.engine.patvar
251254
rv = self.dialog.replvar
252255
replace_all = self.dialog.replace_all
253256

254-
text.insert('insert', '\n')
255-
text.insert('insert', text.get('1.0', 'end')*100)
256-
pv.set('is')
257-
rv.set('was')
257+
text.delete('1.0', 'end')
258+
text.insert('1.0', 'a\na\na\n')
259+
text.mark_set('insert', '2.1')
260+
pv.set('a')
261+
rv.set('b')
258262
replace_all()
259-
self.assertNotIn('is', text.get('1.0', 'end'))
263+
equal(text.get('1.0', '3.end'), 'b\nb\nb') # Wrapped around.
260264

265+
# An empty regular expression is reported as an error.
261266
self.engine.revar.set(True)
262267
pv.set('')
263268
replace_all()
264269
self.assertIn('error', showerror.title)
265270
self.assertIn('Empty', showerror.message)
266271

272+
# An invalid replacement expression is reported as an error,
273+
# and nothing is replaced.
274+
text.delete('1.0', 'end')
275+
text.insert('1.0', 'asT')
267276
pv.set('[s][T]')
268277
rv.set('\\')
269278
replace_all()
279+
self.assertIn('error', showerror.title)
280+
self.assertIn('Invalid Replace Expression', showerror.message)
281+
equal(text.get('1.0', '1.end'), 'asT')
270282

283+
# A pattern that is not present replaces nothing.
271284
self.engine.revar.set(False)
285+
text.delete('1.0', 'end')
286+
text.insert('1.0', 'unchanged')
272287
pv.set('text which is not present')
273288
rv.set('foobar')
274289
replace_all()
290+
equal(text.get('1.0', '1.end'), 'unchanged')
291+
292+
def test_replace_all_backwards_no_wrap(self):
293+
# gh-71956: 'up' without wrap replaces all matches from the start
294+
# of the text down to the current position, not just one up.
295+
equal = self.assertEqual
296+
text = self.text
297+
pv = self.engine.patvar
298+
rv = self.dialog.replvar
299+
replace_all = self.dialog.replace_all
300+
self.engine.backvar.set(True)
301+
self.engine.wrapvar.set(False)
302+
303+
text.delete('1.0', 'end')
304+
text.insert('1.0', 'a\na\na\n')
305+
text.mark_set('insert', '2.1')
306+
pv.set('a')
307+
rv.set('b')
308+
replace_all()
309+
equal(text.get('1.0', '1.end'), 'b') # Above the cursor.
310+
equal(text.get('2.0', '2.end'), 'b') # At the cursor.
311+
equal(text.get('3.0', '3.end'), 'a') # Below the cursor, untouched.
312+
313+
def test_replace_all_forwards_no_wrap(self):
314+
# 'down' without wrap replaces all matches from the current
315+
# position to the end of the text, and none before it.
316+
equal = self.assertEqual
317+
text = self.text
318+
pv = self.engine.patvar
319+
rv = self.dialog.replvar
320+
replace_all = self.dialog.replace_all
321+
self.engine.backvar.set(False)
322+
self.engine.wrapvar.set(False)
323+
324+
text.delete('1.0', 'end')
325+
text.insert('1.0', 'a\na\na\n')
326+
text.mark_set('insert', '2.1')
327+
pv.set('a')
328+
rv.set('b')
329+
replace_all()
330+
equal(text.get('1.0', '1.end'), 'a') # Before the cursor, untouched.
331+
equal(text.get('2.0', '2.end'), 'a') # Before the cursor, untouched.
332+
equal(text.get('3.0', '3.end'), 'b') # After the cursor.
333+
334+
def test_replace_all_backwards_wrap(self):
335+
# With wrap around, an 'up' search also replaces every match.
336+
equal = self.assertEqual
337+
text = self.text
338+
pv = self.engine.patvar
339+
rv = self.dialog.replvar
340+
replace_all = self.dialog.replace_all
341+
self.engine.backvar.set(True)
342+
self.engine.wrapvar.set(True)
343+
344+
text.delete('1.0', 'end')
345+
text.insert('1.0', 'a\na\na\n')
346+
text.mark_set('insert', '2.1')
347+
pv.set('a')
348+
rv.set('b')
349+
replace_all()
350+
equal(text.get('1.0', '3.end'), 'b\nb\nb')
275351

276352
def test_default_command(self):
277353
text = self.text

Lib/idlelib/replace.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,13 @@ def _replace_expand(self, m, repl):
122122
def replace_all(self, event=None):
123123
"""Handle the Replace All button.
124124
125-
Search text for occurrences of the Find value and replace
126-
each of them. The 'wrap around' value controls the start
127-
point for searching. If wrap isn't set, then the searching
128-
starts at the first occurrence after the current selection;
129-
if wrap is set, the replacement starts at the first line.
130-
The replacement is always done top-to-bottom in the text.
125+
Search text for occurrences of the Find value and replace each
126+
of them. The 'wrap around' and direction values control which
127+
occurrences are replaced. With wrap around, every occurrence is
128+
replaced. Without it, a forward search replaces occurrences from
129+
the current position to the end of the text, and a backward search
130+
replaces occurrences from the beginning of the text to the current
131+
position. The replacement is always done top-to-bottom.
131132
"""
132133
prog = self.engine.getprog()
133134
if not prog:
@@ -142,22 +143,32 @@ def replace_all(self, event=None):
142143
text.tag_remove("hit", "1.0", "end")
143144
line = res[0]
144145
col = res[1].start()
146+
# For a backward search without wrap, replace top-to-bottom from
147+
# the start of the text down to the first match at or above the
148+
# current position (gh-71956). A mark tracks that stop point.
149+
stop = None
145150
if self.engine.iswrap():
146151
line = 1
147152
col = 0
153+
elif self.engine.isback():
154+
stop = "replace_all_stop"
155+
text.mark_set(stop, "%d.%d" % (line, res[1].end()))
156+
line = 1
157+
col = 0
148158
ok = True
149159
first = last = None
150160
# XXX ought to replace circular instead of top-to-bottom when wrapping
151161
text.undo_block_start()
152162
while res := self.engine.search_forward(
153163
text, prog, line, col, wrap=False, ok=ok):
154164
line, m = res
155-
chars = text.get("%d.0" % line, "%d.0" % (line+1))
165+
i, j = m.span()
166+
if stop is not None and text.compare("%d.%d" % (line, i), ">=", stop):
167+
break
156168
orig = m.group()
157169
new = self._replace_expand(m, repl)
158170
if new is None:
159171
break
160-
i, j = m.span()
161172
first = "%d.%d" % (line, i)
162173
last = "%d.%d" % (line, j)
163174
if new == orig:
@@ -170,6 +181,8 @@ def replace_all(self, event=None):
170181
text.insert(first, new, self.insert_tags)
171182
col = i + len(new)
172183
ok = False
184+
if stop is not None:
185+
text.mark_unset(stop)
173186
text.undo_block_stop()
174187
if first and last:
175188
self.show_hit(first, last)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix Replace All in the IDLE editor's Replace dialog when the search
2+
direction is "Up" and "Wrap around" is off: it now replaces all matches
3+
above the current position instead of only the first one.

0 commit comments

Comments
 (0)