Skip to content

Commit 8ca62c7

Browse files
authored
Merge pull request #3517 from SamuelRoettgermann/fix-codeblock-false-positives
Fix Codeblock False Positives
2 parents 5c12e8f + 2ade4a3 commit 8ca62c7

4 files changed

Lines changed: 181 additions & 12 deletions

File tree

bot/exts/info/codeblock/_instructions.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ def _get_bad_ticks_message(code_block: _parsing.CodeBlock) -> str | None:
3838
valid_ticks = f"\\{_parsing.BACKTICK}" * 3
3939
instructions = (
4040
"You are using the wrong character instead of backticks. "
41-
f"Use {valid_ticks}, not `{code_block.tick * 3}`."
41+
f"Use {valid_ticks}, not `{code_block.ticks}`."
4242
)
4343

4444
log.trace("Check if the bad ticks code block also has issues with the language specifier.")
4545
addition_msg = _get_bad_lang_message(code_block.content)
4646
if not addition_msg and not code_block.language:
47-
addition_msg = _get_no_lang_message(code_block.content)
47+
addition_msg = _get_no_lang_message(code_block)
4848

4949
# Combine the back ticks message with the language specifier message. The latter will
5050
# already have an example code block.
@@ -112,15 +112,15 @@ def _get_bad_lang_message(content: str) -> str | None:
112112
return None
113113

114114

115-
def _get_no_lang_message(content: str) -> str | None:
115+
def _get_no_lang_message(code_block: _parsing.CodeBlock) -> str | None:
116116
"""
117117
Return instructions on specifying a language for a code block.
118118
119119
If `content` is not valid Python or Python REPL code, return None.
120120
"""
121121
log.trace("Creating instructions for a missing language.")
122122

123-
if _parsing.is_python_code(content):
123+
if code_block.is_python:
124124
example_blocks = _get_example("py")
125125

126126
# Note that _get_bad_ticks_message expects the first line to have two newlines.
@@ -138,7 +138,7 @@ def get_instructions(content: str) -> str | None:
138138
"""
139139
log.trace("Getting formatting instructions.")
140140

141-
blocks = _parsing.find_code_blocks(content)
141+
blocks = _parsing.find_faulty_code_blocks(content)
142142
if blocks is None:
143143
log.trace("At least one valid code block found; no instructions to return.")
144144
return None
@@ -160,6 +160,6 @@ def get_instructions(content: str) -> str | None:
160160
# Check for a bad language first to avoid parsing content into an AST.
161161
instructions = _get_bad_lang_message(block.content)
162162
if not instructions:
163-
instructions = _get_no_lang_message(block.content)
163+
instructions = _get_no_lang_message(block)
164164

165165
return instructions

bot/exts/info/codeblock/_parsing.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from collections.abc import Sequence
77
from typing import NamedTuple
88

9+
import regex
10+
911
from bot import constants
1012
from bot.log import get_logger
1113
from bot.utils import has_lines
@@ -33,6 +35,8 @@
3335

3436
_RE_CODE_BLOCK = re.compile(
3537
fr"""
38+
(?:^| # the ticks need to start at the front of a line to be recognized
39+
\s) # or need to have a preceding whitespace (to avoid detection of words like I'll).
3640
(?P<ticks>
3741
(?P<tick>[{''.join(_TICKS)}]) # Put all ticks into a character class within a group.
3842
\2* # Match previous group up to N more times to ensure the same char.
@@ -43,6 +47,8 @@
4347
""",
4448
re.DOTALL | re.VERBOSE
4549
)
50+
# copy of _RE_CODE_BLOCK. Done like this for highlighting reasons (regex.compile doesn't properly highlight)
51+
_REGEX_CODE_BLOCK = regex.compile(_RE_CODE_BLOCK.pattern, regex.DOTALL | regex.VERBOSE)
4652

4753
_RE_LANGUAGE = re.compile(
4854
fr"""
@@ -59,7 +65,9 @@ class CodeBlock(NamedTuple):
5965

6066
content: str
6167
language: str
68+
ticks: str
6269
tick: str
70+
is_python: bool
6371

6472

6573
class BadLanguage(NamedTuple):
@@ -70,9 +78,9 @@ class BadLanguage(NamedTuple):
7078
has_terminal_newline: bool
7179

7280

73-
def find_code_blocks(message: str) -> Sequence[CodeBlock] | None:
81+
def find_faulty_code_blocks(message: str) -> Sequence[CodeBlock] | None:
7482
"""
75-
Find and return all Markdown code blocks in the `message`.
83+
Find and return all faulty Markdown code blocks in the `message`.
7684
7785
Code blocks with 3 or fewer lines are excluded.
7886
@@ -83,7 +91,7 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock] | None:
8391
log.trace("Finding all code blocks in a message.")
8492

8593
code_blocks = []
86-
for match in _RE_CODE_BLOCK.finditer(message):
94+
for match in _REGEX_CODE_BLOCK.finditer(message, overlapped=True):
8795
# Used to ensure non-matched groups have an empty string as the default value.
8896
groups = match.groupdict("")
8997
language = groups["lang"].strip() # Strip the whitespace cause it's included in the group.
@@ -92,11 +100,19 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock] | None:
92100
log.trace("Message has a valid code block with a language; returning None.")
93101
return None
94102

95-
if has_lines(groups["code"], constants.CodeBlock.minimum_lines):
96-
code_block = CodeBlock(groups["code"], language, groups["tick"])
103+
if not has_lines(groups["code"], constants.CodeBlock.minimum_lines):
104+
log.trace("Skipped a code block shorter than 4 lines.")
105+
continue
106+
107+
is_python = is_python_code(groups["code"])
108+
if (groups["tick"] == BACKTICK
109+
or (language in PY_LANG_CODES and is_python)
110+
or len(groups["ticks"]) >= 2):
111+
log.trace("Message has an invalid code block.")
112+
code_block = CodeBlock(groups["code"], language, groups["ticks"], groups["tick"], is_python)
97113
code_blocks.append(code_block)
98114
else:
99-
log.trace("Skipped a code block shorter than 4 lines.")
115+
log.trace("Skipped invalid code block due to uncertainty if it is supposed to be a code block.")
100116

101117
return code_blocks
102118

tests/bot/exts/info/codeblock/__init__.py

Whitespace-only changes.
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import unittest
2+
3+
from bot.exts.info.codeblock import _parsing as parsing
4+
5+
6+
class FindFaultyCodeblocksTest(unittest.TestCase):
7+
def test_should_recognize_missing_language(self):
8+
message = """```
9+
x = 4
10+
y = 2
11+
print("abc")
12+
```"""
13+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
14+
self.assertIsNotNone(faulty_code_blocks)
15+
self.assertEqual(len(faulty_code_blocks), 1)
16+
17+
def test_should_recognize_contained_codeblock(self):
18+
message = """'
19+
wouldn't it be easier to do:
20+
```py
21+
say_hi = lambda:
22+
print('hello')
23+
print('world')
24+
say_hi()
25+
26+
'
27+
```
28+
29+
'"""
30+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
31+
self.assertIsNone(faulty_code_blocks)
32+
33+
def test_should_recognize_contained_codeblock_even_if_that_breaks_formatting(self):
34+
message = """```
35+
```py
36+
x = 4
37+
y = 3
38+
z = 2
39+
print("abc")
40+
```
41+
```
42+
"""
43+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
44+
self.assertIsNone(faulty_code_blocks)
45+
46+
def test_should_not_recognize_normal_single_quotes(self):
47+
"""normal single quotes refers to single quotes that appear normally in text,
48+
like for example in "I'll", "We're", etc."""
49+
message = """I'm writing line 1
50+
and we're writing line 2
51+
we'll also be checking another of those
52+
and some odd 'variations
53+
isn't it beautiful?"""
54+
55+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
56+
self.assertIsNotNone(faulty_code_blocks)
57+
self.assertEqual(len(faulty_code_blocks), 0)
58+
59+
def test_should_not_recognize_quoting_single_quotes(self):
60+
message = """ 'I am doing a long quote.
61+
Sure, I could just use the > character
62+
for correct quoting
63+
but whatever...
64+
End of quote' """
65+
66+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
67+
self.assertIsNotNone(faulty_code_blocks)
68+
self.assertEqual(len(faulty_code_blocks), 0)
69+
70+
71+
def test_should_not_recognize_normal_double_quotes(self):
72+
"""normal double quotes refer to double quotes that appear normally in text to quote something"""
73+
message = """ "I am doing a long quote.
74+
Sure, I could just use the > character
75+
for correct quoting
76+
but whatever...
77+
End of quote" """
78+
79+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
80+
self.assertIsNotNone(faulty_code_blocks)
81+
self.assertEqual(len(faulty_code_blocks), 0)
82+
83+
def test_should_not_recognize_normal_double_quotes_python_text(self):
84+
message = """ "python is a great language
85+
great
86+
great
87+
great language
88+
enough lines?
89+
yes" """
90+
91+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
92+
self.assertIsNotNone(faulty_code_blocks)
93+
self.assertEqual(len(faulty_code_blocks), 0)
94+
95+
def test_should_recognize_single_backtick_no_language(self):
96+
message = """`
97+
x = 4
98+
y = 3
99+
z = 2
100+
print("abc")
101+
`"""
102+
103+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
104+
self.assertIsNotNone(faulty_code_blocks)
105+
self.assertEqual(len(faulty_code_blocks), 1)
106+
107+
def test_should_recognize_single_backtick_with_language(self):
108+
message = """`py
109+
x = 4
110+
y = 3
111+
z = 2
112+
print("abc")
113+
`"""
114+
115+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
116+
self.assertIsNotNone(faulty_code_blocks)
117+
self.assertEqual(len(faulty_code_blocks), 1)
118+
119+
def test_should_recognize_single_single_quote_with_py_language(self):
120+
message = """'py
121+
x = 4
122+
y = 3
123+
z = 2
124+
print("abc")
125+
'"""
126+
127+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
128+
self.assertIsNotNone(faulty_code_blocks)
129+
self.assertEqual(len(faulty_code_blocks), 1)
130+
131+
def test_should_recognize_single_single_quote_with_python_language(self):
132+
message = """'python
133+
x = 4
134+
y = 3
135+
z = 2
136+
print("abc")
137+
'"""
138+
139+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
140+
self.assertIsNotNone(faulty_code_blocks)
141+
self.assertEqual(len(faulty_code_blocks), 1)
142+
143+
def test_should_recognize_wrong_number_of_backticks(self):
144+
message = """``py
145+
x = 4
146+
y = 3
147+
z = 2
148+
print("abc")
149+
``"""
150+
151+
faulty_code_blocks = parsing.find_faulty_code_blocks(message)
152+
self.assertIsNotNone(faulty_code_blocks)
153+
self.assertEqual(len(faulty_code_blocks), 1)

0 commit comments

Comments
 (0)