Skip to content

Commit 9ab004d

Browse files
gh-69365: Show search pattern errors in the IDLE search dialogs (GH-157598)
Show the error in red below the entry and move the cursor to the offending character in the pattern, instead of a message box. Find Again without a dialog still uses the message box. Check the pattern as typed, so that the position is right. This also fixes the "Whole word" option with a regular expression: the pattern is now grouped, so that "a|b" is anchored on both sides, and a pattern ending with a backslash is an error instead of matching "\b". Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 01192a8 commit 9ab004d

5 files changed

Lines changed: 88 additions & 7 deletions

File tree

Lib/idlelib/idle_test/test_searchbase.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,16 @@ def test_open_and_close(self):
5656

5757
self.dialog.open(text, searchphrase="hello")
5858
self.assertEqual(self.dialog.ent.get(), 'hello')
59+
60+
# While open, the dialog shows pattern errors (gh-69365).
61+
self.assertEqual(self.engine.error_handler, self.dialog.show_error)
62+
self.dialog.show_error('Error: spam', 1)
63+
self.assertEqual(self.dialog.error_label['text'], 'Error: spam')
64+
self.assertEqual(self.dialog.ent.index('insert'), 1)
65+
self.engine.patvar.set('eggs') # Editing the pattern clears it.
66+
self.assertEqual(self.dialog.error_label['text'], '')
67+
self.dialog.close()
68+
self.assertIsNone(self.engine.error_handler)
5969
toplevel.update_idletasks()
6070
toplevel.destroy()
6171

Lib/idlelib/idle_test/test_searchengine.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def test_getcookedpat(self):
154154
engine.setpat('hello')
155155
Equal(engine.getcookedpat(), 'hello')
156156
engine.wordvar.set(True)
157-
Equal(engine.getcookedpat(), r'\bhello\b')
157+
Equal(engine.getcookedpat(), r'\b(?:hello)\b')
158158
engine.wordvar.set(False)
159159

160160
engine.setpat(r'\s')
@@ -182,6 +182,24 @@ def test_getprog(self):
182182
Equal(engine.getprog(), None)
183183
Equal(Mbox.showerror.message,
184184
'Error: nothing to repeat\nPattern: +\nOffset: 0')
185+
# Errors are reported for the pattern as typed, not as cooked.
186+
engine.wordvar.set(True)
187+
engine.setpat('a\\')
188+
Equal(engine.getprog(), None)
189+
Equal(Mbox.showerror.message,
190+
'Error: bad escape (end of pattern)\nPattern: a\\\nOffset: 1')
191+
engine.setpat('a|b')
192+
Equal(engine.getprog().pattern, r'\b(?:a|b)\b')
193+
engine.setpat(')(')
194+
Equal(engine.getprog(), None)
195+
Equal(Mbox.showerror.message,
196+
'Error: unbalanced parenthesis\nPattern: )(\nOffset: 0')
197+
engine.setpat('(?i)x')
198+
Equal(engine.getprog(), None)
199+
Equal(Mbox.showerror.message,
200+
'Error: global flags like (?i) cannot be used with the '
201+
'"Whole word" option\nPattern: (?i)x')
202+
engine.wordvar.set(False)
185203

186204
def test_report_error(self):
187205
showerror = Mbox.showerror
@@ -199,6 +217,17 @@ def test_report_error(self):
199217
expected_message += "\nOffset: 5"
200218
Equal(showerror.message, expected_message)
201219

220+
# An open dialog shows the message itself (gh-69365).
221+
messages = []
222+
self.engine.error_handler = lambda msg, pos: messages.append((msg, pos))
223+
self.addCleanup(setattr, self.engine, 'error_handler', None)
224+
showerror.message = None
225+
Equal(self.engine.report_error(pat, msg, 3), None)
226+
Equal(messages, [("Error: " + msg + " at position 3", 3)])
227+
Equal(showerror.message, None)
228+
Equal(self.engine.report_error(pat, "Empty"), None)
229+
Equal(messages[-1], ("Error: Empty", None))
230+
202231

203232
class SearchTest(unittest.TestCase):
204233
# Test that search_text makes right call to right method.

Lib/idlelib/searchbase.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,30 @@ def open(self, text, searchphrase=None):
6464
self.ent.selection_range(0, "end")
6565
self.ent.icursor(0)
6666
self.top.grab_set()
67+
self.show_error('')
68+
self.engine.error_handler = self.show_error
6769

6870
def close(self, event=None):
6971
"Put dialog away for later use."
7072
if self.top:
73+
self.engine.error_handler = None
7174
self.top.grab_release()
7275
self.top.transient('')
7376
self.top.withdraw()
7477

78+
def show_error(self, message, pos=None):
79+
"""Show message (or nothing) in red below the entries.
80+
81+
If pos is given, move the cursor to that position of the pattern.
82+
"""
83+
self.error_label['text'] = message
84+
if message:
85+
self.bell()
86+
if pos is not None:
87+
self.ent.focus_set()
88+
self.ent.selection_clear()
89+
self.ent.icursor(pos)
90+
7591
def create_widgets(self):
7692
'''Create basic 3 row x 3 col search (find) dialog.
7793
@@ -96,6 +112,7 @@ def create_widgets(self):
96112
self.frame.grid_columnconfigure(1, pad=2, minsize=100, weight=100)
97113

98114
self.create_entries() # row 0 (and maybe 1), cols 0, 1
115+
self.create_error_label() # next row, col 1
99116
self.create_option_buttons() # next row, cols 0, 1
100117
self.create_other_buttons() # next row, cols 0, 1
101118
self.create_command_buttons() # col 2, all rows
@@ -117,6 +134,13 @@ def create_entries(self):
117134
"Create one or more entry lines with make_entry."
118135
self.ent = self.make_entry("Find:", self.engine.patvar)[0]
119136

137+
def create_error_label(self):
138+
"Create the label for a pattern error, cleared when the pattern changes."
139+
self.error_label = Label(self.frame, text=' ', foreground='red')
140+
self.error_label.grid(row=self.row, column=1, sticky="nw")
141+
self.row = self.row + 1
142+
self.engine.patvar.trace_add('write', lambda *args: self.show_error(''))
143+
120144
def make_frame(self,labeltext=None):
121145
'''Return (frame, label).
122146

Lib/idlelib/searchengine.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def __init__(self, root):
3131
self.wordvar = BooleanVar(root, False) # match whole word?
3232
self.wrapvar = BooleanVar(root, True) # wrap around buffer?
3333
self.backvar = BooleanVar(root, False) # search backwards?
34+
self.error_handler = None # Set by an open dialog, see report_error.
3435

3536
# Access methods
3637

@@ -69,7 +70,7 @@ def getcookedpat(self):
6970
if not self.isre(): # if True, see setcookedpat
7071
pat = re.escape(pat)
7172
if self.isword():
72-
pat = r"\b%s\b" % pat
73+
pat = r"\b(?:%s)\b" % pat
7374
return pat
7475

7576
def getprog(self):
@@ -78,19 +79,34 @@ def getprog(self):
7879
if not pat:
7980
self.report_error(pat, "Empty regular expression")
8081
return None
81-
pat = self.getcookedpat()
8282
flags = 0
8383
if not self.iscase():
8484
flags = flags | re.IGNORECASE
85+
if self.isre():
86+
# Check the pattern as typed, so that an error is reported
87+
# at the right position.
88+
try:
89+
re.compile(pat, flags)
90+
except re.PatternError as e:
91+
self.report_error(pat, e.msg, e.pos)
92+
return None
8593
try:
86-
prog = re.compile(pat, flags)
94+
return re.compile(self.getcookedpat(), flags)
8795
except re.PatternError as e:
88-
self.report_error(pat, e.msg, e.pos)
96+
msg = e.msg
97+
if msg.startswith('global flags not at the start'):
98+
msg = ('global flags like (?i) cannot be used '
99+
'with the "Whole word" option')
100+
self.report_error(pat, msg)
89101
return None
90-
return prog
91102

92103
def report_error(self, pat, msg, col=None):
93-
# Derived class could override this with something fancier
104+
"Show msg in the open dialog, if any, else in a message box."
105+
if self.error_handler is not None:
106+
if col is not None:
107+
msg = f"{msg} at position {col}"
108+
self.error_handler("Error: " + str(msg), col)
109+
return
94110
msg = "Error: " + str(msg)
95111
if pat:
96112
msg = msg + "\nPattern: " + str(pat)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
The IDLE search dialogs now show a regular expression error below the entry
2+
instead of in a message box.

0 commit comments

Comments
 (0)