diff --git a/sopel/builtins/find.py b/sopel/builtins/find.py index db6528252..67721d593 100644 --- a/sopel/builtins/find.py +++ b/sopel/builtins/find.py @@ -121,11 +121,11 @@ def kick_cleanup(bot, trigger): [:,]\s+)? # Followed by optional colon/comma and whitespace s(?P/) # The literal s and a separator / as group 2 (?P # Group 3 is the thing to find - (?:\\/|[^/])+ # One or more non-slashes or escaped slashes + (?:\\\\|\\/|[^/])+ # One or more non-slashes or escaped slashes ) / # The separator again (?P # Group 4 is what to replace with - (?:\\/|[^/])* # One or more non-slashes or escaped slashes + (?:\\\\|\\/|[^/])* # One or more non-slashes or escaped slashes ) (?:/ # Optional separator followed by group 5 (flags) (?P\S+) @@ -136,11 +136,11 @@ def kick_cleanup(bot, trigger): [:,]\s+)? # Followed by optional colon/comma and whitespace s(?P\|) # The literal s and a separator | as group 2 (?P # Group 3 is the thing to find - (?:\\\||[^|])+ # One or more non-pipe or escaped pipe + (?:\\\\|\\\||[^|])+ # One or more non-pipe or escaped pipe ) \| # The separator again (?P # Group 4 is what to replace with - (?:\\\||[^|])* # One or more non-pipe or escaped pipe + (?:\\\\|\\\||[^|])* # One or more non-pipe or escaped pipe ) (?:\| # Optional separator followed by group 5 (flags) (?P\S+) @@ -161,14 +161,16 @@ def findandreplace(bot, trigger): return sep = trigger.group('sep') - old = trigger.group('old').replace('\\%s' % sep, sep) + escape_sequence_pattern = re.compile(r'\\[\\%s]' % sep) + + old = escape_sequence_pattern.sub(decode_escape, trigger.group('old')) new = trigger.group('new') me = False # /me command flags = trigger.group('flags') or '' # only clean/format the new string if it's non-empty if new: - new = bold(new.replace('\\%s' % sep, sep)) + new = escape_sequence_pattern.sub(decode_escape, new) # If g flag is given, replace all. Otherwise, replace once. if 'g' in flags: @@ -181,39 +183,49 @@ def findandreplace(bot, trigger): if 'i' in flags: regex = re.compile(re.escape(old), re.U | re.I) - def repl(s): - return re.sub(regex, new, s, count == 1) + def repl(line, subst): + return re.sub(regex, subst, line, count == 1) else: - def repl(s): - return s.replace(old, new, count) + def repl(line, subst): + return line.replace(old, subst, count) # Look back through the user's lines in the channel until you find a line # where the replacement works - new_phrase = None + new_line = new_display = None for line in history: if line.startswith("\x01ACTION"): me = True # /me command line = line[8:] else: me = False - replaced = repl(line) + replaced = repl(line, new) if replaced != line: # we are done - new_phrase = replaced + new_line = replaced + new_display = repl(line, bold(new)) break - if not new_phrase: + if not new_line: return # Didn't find anything # Save the new "edited" message. action = (me and '\x01ACTION ') or '' # If /me message, prepend \x01ACTION - history.appendleft(action + new_phrase) # history is in most-recent-first order + history.appendleft(action + new_line) # history is in most-recent-first order # output if not me: - new_phrase = 'meant to say: %s' % new_phrase + new_display = 'meant to say: %s' % new_display if trigger.group(1): - phrase = '%s thinks %s %s' % (trigger.nick, rnick, new_phrase) + msg = '%s thinks %s %s' % (trigger.nick, rnick, new_display) else: - phrase = '%s %s' % (trigger.nick, new_phrase) + msg = '%s %s' % (trigger.nick, new_display) + + bot.say(msg) + - bot.say(phrase) +def decode_escape(match): + print("Substituting %s" % match.group(0)) + return { + r'\\': '\\', + r'\|': '|', + r'\/': '/', + }[match.group(0)] diff --git a/test/builtins/test_builtins_find.py b/test/builtins/test_builtins_find.py new file mode 100644 index 000000000..f7daaa1f2 --- /dev/null +++ b/test/builtins/test_builtins_find.py @@ -0,0 +1,107 @@ +"""Tests for Sopel's ``find`` plugin""" +from __future__ import annotations + +import pytest + +from sopel.formatting import bold +from sopel.tests import rawlist + + +TMP_CONFIG = """ +[core] +owner = Admin +nick = Sopel +enable = + find +host = irc.libera.chat +""" + + +@pytest.fixture +def bot(botfactory, configfactory): + settings = configfactory('default.ini', TMP_CONFIG) + return botfactory.preloaded(settings, ['find']) + + +@pytest.fixture +def irc(bot, ircfactory): + return ircfactory(bot) + + +@pytest.fixture +def user(userfactory): + return userfactory('User') + + +@pytest.fixture +def other_user(userfactory): + return userfactory('other_user') + + +@pytest.fixture +def channel(): + return '#testing' + + +REPLACES_THAT_WORK = ( + ("A simple line.", r"s/line/message/", f"A simple {bold('message')}."), + ("An escaped / line.", r"s/\//slash/", f"An escaped {bold('slash')} line."), + ("A piped line.", r"s|line|replacement|", f"A piped {bold('replacement')}."), + ("An escaped | line.", r"s|\||pipe|", f"An escaped {bold('pipe')} line."), + ("An escaped \\ line.", r"s/\\/backslash/", f"An escaped {bold('backslash')} line."), + ("abABab", r"s/b/c/g", "abABab".replace('b', bold('c'))), # g (global) flag + ("ABabAB", r"s/b/c/i", f"A{bold('c')}abAB"), # i (case-insensitive) flag + ("ABabAB", r"s/b/c/ig", f"A{bold('c')}a{bold('c')}A{bold('c')}"), # both flags +) + + +@pytest.mark.parametrize('original, command, result', REPLACES_THAT_WORK) +def test_valid_replacements(bot, irc, user, channel, original, command, result): + """Verify that basic replacement functionality works.""" + irc.channel_joined(channel, [user.nick]) + + irc.say(user, channel, original) + irc.say(user, channel, command) + + assert len(bot.backend.message_sent) == 1, ( + "The bot should respond with exactly one line.") + assert bot.backend.message_sent == rawlist( + "PRIVMSG %s :%s meant to say: %s" % (channel, user.nick, result), + ) + + +def test_multiple_users(bot, irc, user, other_user, channel): + """Verify that correcting another user's line works.""" + irc.channel_joined(channel, [user.nick, other_user.nick]) + + irc.say(other_user, channel, 'Some weather we got yesterday') + irc.say(user, channel, '%s: s/yester/to/' % other_user.nick) + + assert len(bot.backend.message_sent) == 1, ( + "The bot should respond with exactly one line.") + assert bot.backend.message_sent == rawlist( + "PRIVMSG %s :%s thinks %s meant to say: %s" % ( + channel, user.nick, other_user.nick, + f"Some weather we got {bold('to')}day", + ), + ) + + +def test_replace_the_replacement(bot, irc, user, channel): + """Verify replacing text that was already replaced.""" + irc.channel_joined(channel, [user.nick]) + + irc.say(user, channel, 'spam') + irc.say(user, channel, 's/spam/eggs/') + irc.say(user, channel, 's/eggs/bacon/') + + assert len(bot.backend.message_sent) == 2, ( + "The bot should respond twice.") + assert bot.backend.message_sent == rawlist( + "PRIVMSG %s :%s meant to say: %s" % ( + channel, user.nick, bold('eggs'), + ), + "PRIVMSG %s :%s meant to say: %s" % ( + channel, user.nick, bold('bacon'), + ), + )