diff --git a/Lib/idlelib/idle_test/test_replace.py b/Lib/idlelib/idle_test/test_replace.py index 6c07389b29ad455..5342a3856ef65d9 100644 --- a/Lib/idlelib/idle_test/test_replace.py +++ b/Lib/idlelib/idle_test/test_replace.py @@ -246,32 +246,108 @@ def test_replace_backwards(self): 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 diff --git a/Lib/idlelib/replace.py b/Lib/idlelib/replace.py index 3716d841568d309..93d4f1c0c6c573e 100644 --- a/Lib/idlelib/replace.py +++ b/Lib/idlelib/replace.py @@ -122,12 +122,13 @@ def _replace_expand(self, m, repl): 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 @@ def replace_all(self, event=None): 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 @@ def replace_all(self, event=None): 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 @@ def replace_all(self, event=None): 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) diff --git a/Misc/NEWS.d/next/IDLE/2026-07-01-12-00-00.gh-issue-71956.rPlAlL.rst b/Misc/NEWS.d/next/IDLE/2026-07-01-12-00-00.gh-issue-71956.rPlAlL.rst new file mode 100644 index 000000000000000..1a92ed4cd389cf5 --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-01-12-00-00.gh-issue-71956.rPlAlL.rst @@ -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.