Skip to content

Commit e58bf8d

Browse files
committed
gh-159672: Cover keyword repairs across logical statements
1 parent be7b5b5 commit e58bf8d

5 files changed

Lines changed: 96 additions & 25 deletions

File tree

‎Lib/test/test_syntax.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,11 +287,11 @@
287287
288288
>>> [x for x if range(1)]
289289
Traceback (most recent call last):
290-
SyntaxError: 'in' expected after for-loop variables
290+
SyntaxError: invalid syntax. Did you mean 'in'?
291291
292292
>>> tuple(x for x if range(1))
293293
Traceback (most recent call last):
294-
SyntaxError: 'in' expected after for-loop variables
294+
SyntaxError: invalid syntax. Did you mean 'in'?
295295
296296
>>> [x for x() in a]
297297
Traceback (most recent call last):

‎Lib/test/test_traceback.py‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1815,6 +1815,10 @@ class TestKeywordTypoSuggestions(unittest.TestCase):
18151815
("[x for x\nin range(3)\nof x]", "if"),
18161816
("[123 fur x\nin range(3)\nif x]", "for"),
18171817
("for x im n:\n pass", "in"),
1818+
("for x is n:\n pass", "in"),
1819+
("is x in y:\n pass", "if"),
1820+
("try:\n pass\nfinaly:\n pass", "finally"),
1821+
("result = (one\n adn two)", "and"),
18181822
("mach x:", "match"),
18191823
("math x:", "match"),
18201824
("match 1:\n cse 1:", "case"),
@@ -1873,17 +1877,22 @@ def test_no_keyword_suggestion_for_comma_errors(self):
18731877
file=sys.stderr
18741878
)
18751879
'''
1876-
source = textwrap.dedent(code).strip()
1877-
rc, stdout, stderr = assert_python_failure('-c', source)
1878-
stderr_text = stderr.decode('utf-8')
1879-
self.assertIn("Perhaps you forgot a comma", stderr_text)
1880-
self.assertNotIn("Did you mean", stderr_text)
1880+
for source in (textwrap.dedent(code).strip(), "[foo bar]", "f(foo bar)"):
1881+
with self.subTest(source=source):
1882+
rc, stdout, stderr = assert_python_failure('-c', source)
1883+
stderr_text = stderr.decode('utf-8')
1884+
self.assertIn("Perhaps you forgot a comma", stderr_text)
1885+
self.assertNotIn("Did you mean", stderr_text)
18811886

18821887
@cpython_only
18831888
def test_parser_suggestions_need_no_formatting_work(self):
18841889
cases = [
18851890
("def f():\n returm value", "return", 2, 5),
1891+
("a=b=c=d=e=f=g=h=i=0\ndef fn():\n retrun True", "return", 3, 5),
1892+
("def f(a,b):\n if b:\n yield\n retrun a+b", "return", 4, 5),
1893+
("def f():\n for i in range(10):\n yield i\n\ndfe x():", "def", 5, 1),
18861894
("é = 1; impor math", "import", 1, 8),
1895+
("for café is items:\n pass", "in", 1, 10),
18871896
("a = 1\nif True:\n pass\nelso:\n pass", "else", 4, 1),
18881897
("[123 fur x\nin range(3)\nif x]", "for", 1, 6),
18891898
("mach x:\n case 1: pass", "match", 1, 1),

‎Parser/action_helpers.c‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,7 @@ _PyPegen_raise_error_for_missing_comma(Parser *p, expr_ty a, expr_ty b)
986986
if (p->tokens[p->mark - 1]->level == 0) {
987987
return NULL;
988988
}
989+
p->missing_comma = 1;
989990
// For multi-line expressions (like string concatenations), point to the
990991
// last line instead of the first for a more helpful error message.
991992
// Use a->col_offset as the starting column since all strings in the

‎Parser/pegen.c‎

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,7 @@ _PyPegen_Parser_New(struct tok_state *tok, int start_rule, int flags,
10221022
#endif
10231023
p->level = 0;
10241024
p->call_invalid_rules = 0;
1025+
p->missing_comma = 0;
10251026
p->input_exhausted = 0;
10261027
p->keyword_trial = NULL;
10271028
#ifdef Py_DEBUG
@@ -1107,6 +1108,22 @@ add_keyword_typo(KeywordTypo *candidates, int *count, int mark,
11071108
max_cost = Py_MIN(max_cost, 2);
11081109
}
11091110
Py_ssize_t cost = _Py_UTF8_Edit_Cost(name, word, max_cost);
1111+
// Adjacent transpositions are one spelling error. The shared Levenshtein
1112+
// distance counts them as two substitutions.
1113+
if (cost > 2 && PyUnicode_IS_ASCII(name) &&
1114+
PyUnicode_GET_LENGTH(name) == PyUnicode_GET_LENGTH(word))
1115+
{
1116+
const char *text = (const char *)PyUnicode_1BYTE_DATA(name);
1117+
Py_ssize_t i = 0;
1118+
while (text[i] && text[i] == keyword[i]) {
1119+
i++;
1120+
}
1121+
if (text[i] && text[i + 1] && text[i] == keyword[i + 1] &&
1122+
text[i + 1] == keyword[i] && strcmp(text + i + 2, keyword + i + 2) == 0)
1123+
{
1124+
cost = 2;
1125+
}
1126+
}
11101127
Py_DECREF(word);
11111128
if (cost < 0) {
11121129
return -1;
@@ -1227,23 +1244,28 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
12271244
p->fill == 0 || p->fill > MAX_KEYWORD_TYPO_TOKENS ||
12281245
_PyTokenizer_GetInfo(p->tok).input_span.end > MAX_KEYWORD_TYPO_BYTES ||
12291246
p->tokens[p->fill - 1]->type != ENDMARKER ||
1230-
_PyTokenizer_IsInteractive(p->tok) || !PyUnicode_Check(error->msg))
1247+
_PyTokenizer_IsInteractive(p->tok) ||
1248+
!Py_IS_TYPE(error, (PyTypeObject *)PyExc_SyntaxError))
12311249
{
12321250
return NULL;
12331251
}
1234-
const char *message = PyUnicode_AsUTF8(error->msg);
1235-
if (message == NULL || (strcmp(message, "invalid syntax") != 0 &&
1236-
strstr(message, "Perhaps you forgot a comma") == NULL))
1237-
{
1238-
return NULL;
1239-
}
1240-
// Prefer an existing missing-comma diagnostic unless the spelling differs
1241-
// by only one edit (the shared edit-cost function charges two per edit).
1242-
Py_ssize_t max_cost = strcmp(message, "invalid syntax") == 0 ? 4 : 2;
12431252
long error_line = PyLong_AsLong(error->lineno);
1244-
if (error_line < 0) {
1253+
long error_offset = PyLong_AsLong(error->offset);
1254+
if (error_line < 0 || error_offset < 0) {
12451255
return NULL;
12461256
}
1257+
Py_ssize_t error_col = -1;
1258+
if (error_offset > 0 && error->text != NULL && PyUnicode_Check(error->text)) {
1259+
PyObject *prefix = PyUnicode_Substring(error->text, 0, error_offset - 1);
1260+
if (prefix == NULL) {
1261+
return NULL;
1262+
}
1263+
const char *text = PyUnicode_AsUTF8AndSize(prefix, &error_col);
1264+
Py_DECREF(prefix);
1265+
if (text == NULL) {
1266+
return NULL;
1267+
}
1268+
}
12471269
KeywordTypo candidates[MAX_KEYWORD_TYPO_CANDIDATES];
12481270
int count = 0;
12491271
int start = 0;
@@ -1261,15 +1283,44 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
12611283
};
12621284
for (int i = start; i < p->fill; i++) {
12631285
Token *token = p->tokens[i];
1264-
if (token->type != NAME || token->lineno > error_line ||
1265-
PyBytes_GET_SIZE(token->bytes) < 2 ||
1266-
PyBytes_GET_SIZE(token->bytes) > 10)
1267-
{
1286+
// Error spans can start before the typo in a multiline expression.
1287+
// Keep the complete logical statement as the candidate range.
1288+
if (token->type == NEWLINE) {
1289+
break;
1290+
}
1291+
const char *text = token->type == NAME ? PyBytes_AS_STRING(token->bytes) : NULL;
1292+
Py_ssize_t text_length = text == NULL ? 0 : PyBytes_GET_SIZE(token->bytes);
1293+
int is_keyword = token->type != NAME;
1294+
// A keyword can itself be a typo, as in "for x is y". Consider an
1295+
// existing keyword only at the reported error, not throughout the line.
1296+
if (text == NULL && token->lineno == error_line && token->col_offset == error_col) {
1297+
for (int length = 0; length < p->n_keyword_lists && text == NULL; length++) {
1298+
for (KeywordToken *k = p->keywords[length];
1299+
k != NULL && k->type != -1; k++)
1300+
{
1301+
if (k->type == token->type) {
1302+
text = k->str;
1303+
text_length = length;
1304+
break;
1305+
}
1306+
}
1307+
}
1308+
}
1309+
if (text == NULL || text_length < 2 || text_length > 10) {
1310+
continue;
1311+
}
1312+
for (char **k = p->soft_keywords; k != NULL && *k != NULL; k++) {
1313+
if (strcmp(text, *k) == 0) {
1314+
is_keyword = 1;
1315+
break;
1316+
}
1317+
}
1318+
if (is_keyword && (token->lineno != error_line || token->col_offset != error_col)) {
12681319
continue;
12691320
}
12701321
const char *alias = NULL;
12711322
for (size_t j = 0; j < Py_ARRAY_LENGTH(aliases); j++) {
1272-
if (strcmp(PyBytes_AS_STRING(token->bytes), aliases[j].name) == 0) {
1323+
if (strcmp(text, aliases[j].name) == 0) {
12731324
alias = aliases[j].keyword;
12741325
break;
12751326
}
@@ -1281,20 +1332,29 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
12811332
candidates[count++] = (KeywordTypo){i, alias, 0, 1};
12821333
continue;
12831334
}
1284-
PyObject *name = PyUnicode_DecodeUTF8(
1285-
PyBytes_AS_STRING(token->bytes), PyBytes_GET_SIZE(token->bytes), NULL);
1335+
PyObject *name = PyUnicode_DecodeUTF8(text, text_length, NULL);
12861336
if (name == NULL) {
12871337
return NULL;
12881338
}
1339+
// Preserve missing-comma diagnostics and correctly spelled keywords
1340+
// unless one edit repairs the input. For keywords, only substitute a
1341+
// character: deleting one from "for" must not suggest "or".
1342+
Py_ssize_t max_cost = p->missing_comma || is_keyword ? 2 : 4;
12891343
int result = 0;
12901344
for (int length = 0; length < p->n_keyword_lists && result == 0; length++) {
1345+
if (is_keyword && length != text_length) {
1346+
continue;
1347+
}
12911348
for (KeywordToken *k = p->keywords[length];
12921349
k != NULL && k->type != -1 && result == 0; k++)
12931350
{
12941351
result = add_keyword_typo(candidates, &count, i, name, k->str, max_cost);
12951352
}
12961353
}
12971354
for (char **k = p->soft_keywords; k != NULL && *k != NULL && result == 0; k++) {
1355+
if (is_keyword && (Py_ssize_t)strlen(*k) != text_length) {
1356+
continue;
1357+
}
12981358
result = add_keyword_typo(candidates, &count, i, name, *k, max_cost);
12991359
}
13001360
Py_DECREF(name);

‎Parser/pegen.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ typedef struct {
103103
Token *known_err_token;
104104
int level;
105105
int call_invalid_rules;
106+
int missing_comma;
106107
KeywordTrial *keyword_trial;
107108
int debug;
108109
IdentifierCacheEntry *identifier_cache;

0 commit comments

Comments
 (0)