Skip to content

Commit 74d60f4

Browse files
authored
Merge pull request #2589 from sopel-irc/find-escaped-escapechar
find: support escaping backslash, fix double-bolding, and add basic tests
2 parents 44246ba + 18106f8 commit 74d60f4

File tree

2 files changed

+138
-19
lines changed

2 files changed

+138
-19
lines changed

sopel/builtins/find.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,11 @@ def kick_cleanup(bot, trigger):
121121
[:,]\s+)? # Followed by optional colon/comma and whitespace
122122
s(?P<sep>/) # The literal s and a separator / as group 2
123123
(?P<old> # Group 3 is the thing to find
124-
(?:\\/|[^/])+ # One or more non-slashes or escaped slashes
124+
(?:\\\\|\\/|[^/])+ # One or more non-slashes or escaped slashes
125125
)
126126
/ # The separator again
127127
(?P<new> # Group 4 is what to replace with
128-
(?:\\/|[^/])* # One or more non-slashes or escaped slashes
128+
(?:\\\\|\\/|[^/])* # One or more non-slashes or escaped slashes
129129
)
130130
(?:/ # Optional separator followed by group 5 (flags)
131131
(?P<flags>\S+)
@@ -136,11 +136,11 @@ def kick_cleanup(bot, trigger):
136136
[:,]\s+)? # Followed by optional colon/comma and whitespace
137137
s(?P<sep>\|) # The literal s and a separator | as group 2
138138
(?P<old> # Group 3 is the thing to find
139-
(?:\\\||[^|])+ # One or more non-pipe or escaped pipe
139+
(?:\\\\|\\\||[^|])+ # One or more non-pipe or escaped pipe
140140
)
141141
\| # The separator again
142142
(?P<new> # Group 4 is what to replace with
143-
(?:\\\||[^|])* # One or more non-pipe or escaped pipe
143+
(?:\\\\|\\\||[^|])* # One or more non-pipe or escaped pipe
144144
)
145145
(?:\| # Optional separator followed by group 5 (flags)
146146
(?P<flags>\S+)
@@ -161,14 +161,16 @@ def findandreplace(bot, trigger):
161161
return
162162

163163
sep = trigger.group('sep')
164-
old = trigger.group('old').replace('\\%s' % sep, sep)
164+
escape_sequence_pattern = re.compile(r'\\[\\%s]' % sep)
165+
166+
old = escape_sequence_pattern.sub(decode_escape, trigger.group('old'))
165167
new = trigger.group('new')
166168
me = False # /me command
167169
flags = trigger.group('flags') or ''
168170

169171
# only clean/format the new string if it's non-empty
170172
if new:
171-
new = bold(new.replace('\\%s' % sep, sep))
173+
new = escape_sequence_pattern.sub(decode_escape, new)
172174

173175
# If g flag is given, replace all. Otherwise, replace once.
174176
if 'g' in flags:
@@ -181,39 +183,49 @@ def findandreplace(bot, trigger):
181183
if 'i' in flags:
182184
regex = re.compile(re.escape(old), re.U | re.I)
183185

184-
def repl(s):
185-
return re.sub(regex, new, s, count == 1)
186+
def repl(line, subst):
187+
return re.sub(regex, subst, line, count == 1)
186188
else:
187-
def repl(s):
188-
return s.replace(old, new, count)
189+
def repl(line, subst):
190+
return line.replace(old, subst, count)
189191

190192
# Look back through the user's lines in the channel until you find a line
191193
# where the replacement works
192-
new_phrase = None
194+
new_line = new_display = None
193195
for line in history:
194196
if line.startswith("\x01ACTION"):
195197
me = True # /me command
196198
line = line[8:]
197199
else:
198200
me = False
199-
replaced = repl(line)
201+
replaced = repl(line, new)
200202
if replaced != line: # we are done
201-
new_phrase = replaced
203+
new_line = replaced
204+
new_display = repl(line, bold(new))
202205
break
203206

204-
if not new_phrase:
207+
if not new_line:
205208
return # Didn't find anything
206209

207210
# Save the new "edited" message.
208211
action = (me and '\x01ACTION ') or '' # If /me message, prepend \x01ACTION
209-
history.appendleft(action + new_phrase) # history is in most-recent-first order
212+
history.appendleft(action + new_line) # history is in most-recent-first order
210213

211214
# output
212215
if not me:
213-
new_phrase = 'meant to say: %s' % new_phrase
216+
new_display = 'meant to say: %s' % new_display
214217
if trigger.group(1):
215-
phrase = '%s thinks %s %s' % (trigger.nick, rnick, new_phrase)
218+
msg = '%s thinks %s %s' % (trigger.nick, rnick, new_display)
216219
else:
217-
phrase = '%s %s' % (trigger.nick, new_phrase)
220+
msg = '%s %s' % (trigger.nick, new_display)
221+
222+
bot.say(msg)
223+
218224

219-
bot.say(phrase)
225+
def decode_escape(match):
226+
print("Substituting %s" % match.group(0))
227+
return {
228+
r'\\': '\\',
229+
r'\|': '|',
230+
r'\/': '/',
231+
}[match.group(0)]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
"""Tests for Sopel's ``find`` plugin"""
2+
from __future__ import annotations
3+
4+
import pytest
5+
6+
from sopel.formatting import bold
7+
from sopel.tests import rawlist
8+
9+
10+
TMP_CONFIG = """
11+
[core]
12+
owner = Admin
13+
nick = Sopel
14+
enable =
15+
find
16+
host = irc.libera.chat
17+
"""
18+
19+
20+
@pytest.fixture
21+
def bot(botfactory, configfactory):
22+
settings = configfactory('default.ini', TMP_CONFIG)
23+
return botfactory.preloaded(settings, ['find'])
24+
25+
26+
@pytest.fixture
27+
def irc(bot, ircfactory):
28+
return ircfactory(bot)
29+
30+
31+
@pytest.fixture
32+
def user(userfactory):
33+
return userfactory('User')
34+
35+
36+
@pytest.fixture
37+
def other_user(userfactory):
38+
return userfactory('other_user')
39+
40+
41+
@pytest.fixture
42+
def channel():
43+
return '#testing'
44+
45+
46+
REPLACES_THAT_WORK = (
47+
("A simple line.", r"s/line/message/", f"A simple {bold('message')}."),
48+
("An escaped / line.", r"s/\//slash/", f"An escaped {bold('slash')} line."),
49+
("A piped line.", r"s|line|replacement|", f"A piped {bold('replacement')}."),
50+
("An escaped | line.", r"s|\||pipe|", f"An escaped {bold('pipe')} line."),
51+
("An escaped \\ line.", r"s/\\/backslash/", f"An escaped {bold('backslash')} line."),
52+
("abABab", r"s/b/c/g", "abABab".replace('b', bold('c'))), # g (global) flag
53+
("ABabAB", r"s/b/c/i", f"A{bold('c')}abAB"), # i (case-insensitive) flag
54+
("ABabAB", r"s/b/c/ig", f"A{bold('c')}a{bold('c')}A{bold('c')}"), # both flags
55+
)
56+
57+
58+
@pytest.mark.parametrize('original, command, result', REPLACES_THAT_WORK)
59+
def test_valid_replacements(bot, irc, user, channel, original, command, result):
60+
"""Verify that basic replacement functionality works."""
61+
irc.channel_joined(channel, [user.nick])
62+
63+
irc.say(user, channel, original)
64+
irc.say(user, channel, command)
65+
66+
assert len(bot.backend.message_sent) == 1, (
67+
"The bot should respond with exactly one line.")
68+
assert bot.backend.message_sent == rawlist(
69+
"PRIVMSG %s :%s meant to say: %s" % (channel, user.nick, result),
70+
)
71+
72+
73+
def test_multiple_users(bot, irc, user, other_user, channel):
74+
"""Verify that correcting another user's line works."""
75+
irc.channel_joined(channel, [user.nick, other_user.nick])
76+
77+
irc.say(other_user, channel, 'Some weather we got yesterday')
78+
irc.say(user, channel, '%s: s/yester/to/' % other_user.nick)
79+
80+
assert len(bot.backend.message_sent) == 1, (
81+
"The bot should respond with exactly one line.")
82+
assert bot.backend.message_sent == rawlist(
83+
"PRIVMSG %s :%s thinks %s meant to say: %s" % (
84+
channel, user.nick, other_user.nick,
85+
f"Some weather we got {bold('to')}day",
86+
),
87+
)
88+
89+
90+
def test_replace_the_replacement(bot, irc, user, channel):
91+
"""Verify replacing text that was already replaced."""
92+
irc.channel_joined(channel, [user.nick])
93+
94+
irc.say(user, channel, 'spam')
95+
irc.say(user, channel, 's/spam/eggs/')
96+
irc.say(user, channel, 's/eggs/bacon/')
97+
98+
assert len(bot.backend.message_sent) == 2, (
99+
"The bot should respond twice.")
100+
assert bot.backend.message_sent == rawlist(
101+
"PRIVMSG %s :%s meant to say: %s" % (
102+
channel, user.nick, bold('eggs'),
103+
),
104+
"PRIVMSG %s :%s meant to say: %s" % (
105+
channel, user.nick, bold('bacon'),
106+
),
107+
)

0 commit comments

Comments
 (0)