Skip to content

Commit

Permalink
Merge pull request #2589 from sopel-irc/find-escaped-escapechar
Browse files Browse the repository at this point in the history
find: support escaping backslash, fix double-bolding, and add basic tests
  • Loading branch information
dgw authored Oct 14, 2024
2 parents 44246ba + 18106f8 commit 74d60f4
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 19 deletions.
50 changes: 31 additions & 19 deletions sopel/builtins/find.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,11 @@ def kick_cleanup(bot, trigger):
[:,]\s+)? # Followed by optional colon/comma and whitespace
s(?P<sep>/) # The literal s and a separator / as group 2
(?P<old> # 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<new> # 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<flags>\S+)
Expand All @@ -136,11 +136,11 @@ def kick_cleanup(bot, trigger):
[:,]\s+)? # Followed by optional colon/comma and whitespace
s(?P<sep>\|) # The literal s and a separator | as group 2
(?P<old> # 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<new> # 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<flags>\S+)
Expand All @@ -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:
Expand All @@ -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)]
107 changes: 107 additions & 0 deletions test/builtins/test_builtins_find.py
Original file line number Diff line number Diff line change
@@ -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'),
),
)

2 comments on commit 74d60f4

@dgw
Copy link
Member Author

@dgw dgw commented on 74d60f4 Oct 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source PR passed CI just yesterday, so hey guess what? mypy released a few hours ago and broke things for us again.

@dgw
Copy link
Member Author

@dgw dgw commented on 74d60f4 Oct 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Welcome #2631 to work around this until some stuff touching the same code lands and we can explicitly upgrade to mypy 1.12.

Please sign in to comment.