diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py index 57c4b7ad95..2038748978 100644 --- a/bot/exts/info/codeblock/_instructions.py +++ b/bot/exts/info/codeblock/_instructions.py @@ -148,23 +148,7 @@ def get_instructions(content: str) -> str | None: instructions = _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") - bad_ticks = [block for block in blocks if block.tick != _parsing.BACKTICK] - block = None - if bad_ticks: - block = next( - ( - bad_tick - for bad_tick in bad_ticks - if any( - block - for block in blocks - if block != bad_tick - and bad_tick.content != block.content - and bad_tick.content not in block.content - ) - ), - None, - ) + block = _parsing._get_block_with_invalid_ticks(blocks) if block: log.trace("A code block exists but has invalid ticks.") diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index 22cbe4344e..3edfb37a14 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -6,8 +6,6 @@ from collections.abc import Sequence from typing import NamedTuple -import regex - from bot import constants from bot.log import get_logger from bot.utils import has_lines @@ -47,8 +45,6 @@ """, re.DOTALL | re.VERBOSE ) -# copy of _RE_CODE_BLOCK. Done like this for highlighting reasons (regex.compile doesn't properly highlight) -_REGEX_CODE_BLOCK = regex.compile(_RE_CODE_BLOCK.pattern, regex.DOTALL | regex.VERBOSE) _RE_LANGUAGE = re.compile( fr""" @@ -91,7 +87,7 @@ def find_faulty_code_blocks(message: str) -> Sequence[CodeBlock] | None: log.trace("Finding all code blocks in a message.") code_blocks = [] - for match in _REGEX_CODE_BLOCK.finditer(message, overlapped=True): + for match in _RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. groups = match.groupdict("") language = groups["lang"].strip() # Strip the whitespace cause it's included in the group. @@ -249,3 +245,16 @@ def _fix_indentation(content: str) -> str: content = first_line + "".join(line[first_indent:] for line in lines[1:]) return content + + +def _get_block_with_invalid_ticks(blocks: Sequence[CodeBlock]) -> CodeBlock | None: + """ + Find a block with invalid ticks and return it. + + Return `None` if there are no blocks with invalid ticks. + """ + for block in blocks: + if block.tick != BACKTICK: + return block + + return None diff --git a/tests/bot/exts/info/codeblock/test_instructions.py b/tests/bot/exts/info/codeblock/test_instructions.py new file mode 100644 index 0000000000..6d6ca478c1 --- /dev/null +++ b/tests/bot/exts/info/codeblock/test_instructions.py @@ -0,0 +1,113 @@ +import unittest +from textwrap import dedent + +from bot.exts.info.codeblock import _instructions as instructions + + +class ProvideBadTicksInstructionsTest(unittest.TestCase): + def __assert_instructions_are_bad_ticks_ones( + self, + instructions_text: str, + wrong_ticks: str, + ) -> None: + self.assertIn("\\`\\`\\`", instructions_text) + self.assertIn(wrong_ticks, instructions_text) + + def __assert_instructions_contain_no_land_instructions( + self, + instructions_text: str, + ) -> None: + self.assertIn("py", instructions_text) + + def test_should_provide_bad_ticks_and_no_land_instructions_when_no_lang_spec_and_bad_ticks_are_used(self) -> None: + message = dedent(""" + ''' + \"\"\"A script that iterates and prints the numbers\"\"\" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''' + """).strip() + + instructions_text = instructions.get_instructions(message) + + self.assertIsNotNone(instructions_text) + + # Type narrowing + if instructions_text is None: + return + + self.__assert_instructions_are_bad_ticks_ones(instructions_text, "'''") + self.__assert_instructions_contain_no_land_instructions(instructions_text) + + def test_should_provide_bad_ticks_instructions_when_correct_lang_spec_and_bad_ticks_are_used(self) -> None: + message = dedent(""" + '''py + \"\"\"A script that iterates and prints the numbers\"\"\" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''' + """).strip() + + instructions_text = instructions.get_instructions(message) + + self.assertIsNotNone(instructions_text) + + # Type narrowing + if instructions_text is None: + return + + self.__assert_instructions_are_bad_ticks_ones(instructions_text, "'''") + + def test_should_provide_bad_ticks_instructions_when_wrong_lang_spec_and_bad_ticks_are_used(self) -> None: + message = dedent(""" + '''c + \"\"\"A script that iterates and prints the numbers\"\"\" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''' + """).strip() + + instructions_text = instructions.get_instructions(message) + + self.assertIsNotNone(instructions_text) + + # Type narrowing + if instructions_text is None: + return + + self.__assert_instructions_are_bad_ticks_ones(instructions_text, "'''") + + def test_should_provide_bad_ticks_instructions_when_bad_ticks_are_used_in_two_identical_codeblocks(self) -> None: + message = dedent(""" + '''py + \"\"\"Docstring\"\"\" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''' + + '''py + \"\"\"Docstring\"\"\" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''' + """).strip() + + instructions_text = instructions.get_instructions(message) + + self.assertIsNotNone(instructions_text) + + # Type narrowing + if instructions_text is None: + return + + self.__assert_instructions_are_bad_ticks_ones(instructions_text, "'''") diff --git a/tests/bot/exts/info/codeblock/test_parsing.py b/tests/bot/exts/info/codeblock/test_parsing.py index 4507fcaa54..286558f816 100644 --- a/tests/bot/exts/info/codeblock/test_parsing.py +++ b/tests/bot/exts/info/codeblock/test_parsing.py @@ -1,4 +1,5 @@ import unittest +from textwrap import dedent from bot.exts.info.codeblock import _parsing as parsing @@ -30,6 +31,7 @@ def test_should_recognize_contained_codeblock(self): faulty_code_blocks = parsing.find_faulty_code_blocks(message) self.assertIsNone(faulty_code_blocks) + @unittest.expectedFailure def test_should_recognize_contained_codeblock_even_if_that_breaks_formatting(self): message = """``` ```py @@ -67,7 +69,6 @@ def test_should_not_recognize_quoting_single_quotes(self): self.assertIsNotNone(faulty_code_blocks) self.assertEqual(len(faulty_code_blocks), 0) - def test_should_not_recognize_normal_double_quotes(self): """normal double quotes refer to double quotes that appear normally in text to quote something""" message = """ "I am doing a long quote. @@ -92,6 +93,36 @@ def test_should_not_recognize_normal_double_quotes_python_text(self): self.assertIsNotNone(faulty_code_blocks) self.assertEqual(len(faulty_code_blocks), 0) + def test_should_not_recognize_multiline_single_quote_python_text(self) -> None: + message = dedent(""" + ```py + \'\'\'A script that iterates and prints the numbers\'\'\' + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ``` + """).strip() + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + + self.assertIsNone(faulty_code_blocks) + + def test_should_not_recognize_multiline_double_quote_python_text(self) -> None: + message = dedent(''' + ```py + """A script that iterates and prints the numbers""" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ``` + ''').strip() + + faulty_code_blocks = parsing.find_faulty_code_blocks(message) + + self.assertIsNone(faulty_code_blocks) + def test_should_recognize_single_backtick_no_language(self): message = """` x = 4 @@ -151,3 +182,49 @@ def test_should_recognize_wrong_number_of_backticks(self): faulty_code_blocks = parsing.find_faulty_code_blocks(message) self.assertIsNotNone(faulty_code_blocks) self.assertEqual(len(faulty_code_blocks), 1) + + +class FindCodeblockWithInvalidTicksTest(unittest.TestCase): + def test_should_return_codeblock_with_single_quotes(self) -> None: + first_block_content = dedent(''' + """A script that iterates and prints the numbers""" + numbs = [1, 2, 3] + + for numb in numbs: + print(numb) + ''').strip() + first_block_language = "py" + first_block_tick = parsing.BACKTICK + first_block_ticks = first_block_tick * 3 + + second_block_content = dedent(''' + """A script that iterates and prints the letters""" + letters = ["a", "b", "c"] + + for letter in letters: + print(letter) + ''').strip() + second_block_language = "py" + second_block_tick = "'" + second_block_ticks = second_block_tick * 3 + + first_block = parsing.CodeBlock( + first_block_content, + first_block_language, + first_block_ticks, + first_block_tick, + True, + ) + second_block = parsing.CodeBlock( + second_block_content, + second_block_language, + second_block_ticks, + second_block_tick, + True, + ) + blocks = (first_block, second_block) + expected_block = second_block + + block_with_invalid_ticks = parsing._get_block_with_invalid_ticks(blocks) + + self.assertEqual(block_with_invalid_ticks, expected_block)