Skip to content

Commit cadefcb

Browse files
committed
gh-159672: Separate keyword replay stages
Separate candidate collection, trial acceptance, and hint construction. Keep trial allocation and cleanup together, and restore the original syntax error through one exit path.
1 parent daa773f commit cadefcb

2 files changed

Lines changed: 154 additions & 117 deletions

File tree

‎Parser/pegen.c‎

Lines changed: 153 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,7 +1142,43 @@ add_keyword_typo(KeywordTypo *candidates, int *count, int mark,
11421142
}
11431143

11441144
static int
1145-
try_keyword_typo(Parser *p, KeywordTypo *candidate, KeywordTrial *state)
1145+
keyword_trial_accepts(Parser *p, const KeywordTypo *candidate)
1146+
{
1147+
int accepted = _PyPegen_parse(p) != NULL;
1148+
if (p->error_indicator || PyErr_Occurred()) {
1149+
return 0;
1150+
}
1151+
for (int i = p->mark; accepted && i < p->fill; i++) {
1152+
int type = p->tokens[i]->type;
1153+
accepted = type == NEWLINE || type == ENDMARKER;
1154+
}
1155+
if (accepted) {
1156+
return 1;
1157+
}
1158+
// Version checks on an enclosing rule can run after its suite. For older
1159+
// grammars, require a complete repair so those checks execute.
1160+
if (p->feature_version != PY_MINOR_VERSION) {
1161+
return 0;
1162+
}
1163+
// Known foreign keywords can identify a valid header even when its body
1164+
// contains another error (for example, "switch x:\n case:"). Require the
1165+
// ordinary grammar to advance into a later line before accepting that hint.
1166+
if (candidate->is_alias) {
1167+
for (int i = candidate->mark; i < p->keyword_trial->furthest; i++) {
1168+
if (p->tokens[i]->type == NEWLINE) {
1169+
return 1;
1170+
}
1171+
}
1172+
}
1173+
reset_parser_state_for_error_pass(p);
1174+
_PyPegen_parse(p);
1175+
return p->keyword_trial->incomplete_suite;
1176+
}
1177+
1178+
// Return 1 for an accepted repair, 0 for a rejected repair, or -1 to stop the
1179+
// search. Raw allocation failures need not set an exception.
1180+
static int
1181+
try_keyword_typo(Parser *p, const KeywordTypo *candidate, KeywordTrial *state)
11461182
{
11471183
// Token text belongs to the original arena. Token headers and memo entries
11481184
// must be private: even a failed trial can memoize nodes containing its edit.
@@ -1195,31 +1231,7 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate, KeywordTrial *state)
11951231
trial.known_err_token = NULL;
11961232
trial.errcode = NULL;
11971233
trial.normalize = NULL;
1198-
accepted = _PyPegen_parse(&trial) != NULL &&
1199-
!trial.error_indicator && !PyErr_Occurred();
1200-
for (int i = trial.mark; accepted && i < trial.fill; i++) {
1201-
accepted = tokens[i].type == NEWLINE || tokens[i].type == ENDMARKER;
1202-
}
1203-
// Known foreign keywords may identify a valid header even when its body
1204-
// contains another error (for example, "switch x:\n case:"). Require the
1205-
// ordinary grammar to advance into a later line before accepting that hint.
1206-
if (!accepted && !trial.error_indicator && candidate->is_alias &&
1207-
p->feature_version == PY_MINOR_VERSION)
1208-
{
1209-
for (int i = candidate->mark; i < state->furthest; i++) {
1210-
if (tokens[i].type == NEWLINE) {
1211-
accepted = 1;
1212-
break;
1213-
}
1214-
}
1215-
}
1216-
if (!accepted && !trial.error_indicator && !PyErr_Occurred()) {
1217-
reset_parser_state_for_error_pass(&trial);
1218-
_PyPegen_parse(&trial);
1219-
// Version checks on an enclosing rule can run after its suite. For
1220-
// older grammars, require a complete repair so those checks execute.
1221-
accepted = state->incomplete_suite && p->feature_version == PY_MINOR_VERSION;
1222-
}
1234+
accepted = keyword_trial_accepts(&trial, candidate);
12231235
done:
12241236
// No Parser_Free: the trial borrows the tokenizer and type-ignore comments,
12251237
// and its token headers are a single allocation.
@@ -1233,39 +1245,60 @@ try_keyword_typo(Parser *p, KeywordTypo *candidate, KeywordTrial *state)
12331245
return PyErr_Occurred() ? -1 : accepted;
12341246
}
12351247

1236-
static PyObject *
1237-
keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
1248+
static const char *
1249+
keyword_typo_text(Parser *p, Token *token, int at_error, int *is_keyword)
12381250
{
1239-
// A real ENDMARKER is required even for an unfinished suite. A truncated
1240-
// token cache is never evidence that a replacement is valid.
1241-
if ((p->start_rule != Py_file_input && p->start_rule != Py_eval_input &&
1242-
p->start_rule != Py_single_input) ||
1243-
p->fill == 0 || p->fill > MAX_KEYWORD_TYPO_TOKENS ||
1244-
_PyTokenizer_GetInfo(p->tok).input_span.end > MAX_KEYWORD_TYPO_BYTES ||
1245-
p->tokens[p->fill - 1]->type != ENDMARKER ||
1246-
_PyTokenizer_IsInteractive(p->tok) ||
1247-
!Py_IS_TYPE(error, (PyTypeObject *)PyExc_SyntaxError))
1248-
{
1251+
// A keyword can itself be a typo, as in "for x is y". Consider an existing
1252+
// keyword only at the reported error, not throughout the statement.
1253+
*is_keyword = token->type != NAME;
1254+
if (*is_keyword) {
1255+
if (!at_error) {
1256+
return NULL;
1257+
}
1258+
// Hard keywords have no token text. Use the generated keyword table.
1259+
for (int length = 0; length < p->n_keyword_lists; length++) {
1260+
for (KeywordToken *k = p->keywords[length];
1261+
k != NULL && k->type != -1; k++)
1262+
{
1263+
if (k->type == token->type) {
1264+
return k->str;
1265+
}
1266+
}
1267+
}
12491268
return NULL;
12501269
}
1270+
const char *text = PyBytes_AS_STRING(token->bytes);
1271+
for (char **k = p->soft_keywords; k != NULL && *k != NULL; k++) {
1272+
if (strcmp(text, *k) == 0) {
1273+
*is_keyword = 1;
1274+
return at_error ? text : NULL;
1275+
}
1276+
}
1277+
return text;
1278+
}
1279+
1280+
// Return the candidate count, or -1 if allocation or capacity prevents a
1281+
// complete search. Capacity exhaustion does not set an exception.
1282+
static int
1283+
collect_keyword_typos(Parser *p, PySyntaxErrorObject *error, KeywordTypo *candidates)
1284+
{
12511285
long error_line = PyLong_AsLong(error->lineno);
12521286
long error_offset = PyLong_AsLong(error->offset);
12531287
if (error_line < 0 || error_offset < 0) {
1254-
return NULL;
1288+
return -1;
12551289
}
12561290
Py_ssize_t error_col = -1;
12571291
if (error_offset > 0 && error->text != NULL && PyUnicode_Check(error->text)) {
12581292
PyObject *prefix = PyUnicode_Substring(error->text, 0, error_offset - 1);
12591293
if (prefix == NULL) {
1260-
return NULL;
1294+
return -1;
12611295
}
1262-
const char *text = PyUnicode_AsUTF8AndSize(prefix, &error_col);
1296+
int failed = PyUnicode_AsUTF8AndSize(prefix, &error_col) == NULL;
12631297
Py_DECREF(prefix);
1264-
if (text == NULL) {
1265-
return NULL;
1298+
if (failed) {
1299+
return -1;
12661300
}
12671301
}
1268-
KeywordTypo candidates[MAX_KEYWORD_TYPO_CANDIDATES];
12691302
int count = 0;
12701303
int start = 0;
12711304
for (int i = 0; i < p->fill && p->tokens[i]->lineno < error_line; i++) {
@@ -1287,34 +1320,15 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
12871320
if (token->type == NEWLINE) {
12881321
break;
12891322
}
1290-
const char *text = token->type == NAME ? PyBytes_AS_STRING(token->bytes) : NULL;
1291-
Py_ssize_t text_length = text == NULL ? 0 : PyBytes_GET_SIZE(token->bytes);
1292-
int is_keyword = token->type != NAME;
1293-
// A keyword can itself be a typo, as in "for x is y". Consider an
1294-
// existing keyword only at the reported error, not throughout the line.
1295-
if (text == NULL && token->lineno == error_line && token->col_offset == error_col) {
1296-
for (int length = 0; length < p->n_keyword_lists && text == NULL; length++) {
1297-
for (KeywordToken *k = p->keywords[length];
1298-
k != NULL && k->type != -1; k++)
1299-
{
1300-
if (k->type == token->type) {
1301-
text = k->str;
1302-
text_length = length;
1303-
break;
1304-
}
1305-
}
1306-
}
1307-
}
1308-
if (text == NULL || text_length < 2 || text_length > 10) {
1323+
int at_error = token->lineno == error_line && token->col_offset == error_col;
1324+
int is_keyword;
1325+
const char *text = keyword_typo_text(p, token, at_error, &is_keyword);
1326+
if (text == NULL) {
13091327
continue;
13101328
}
1311-
for (char **k = p->soft_keywords; k != NULL && *k != NULL; k++) {
1312-
if (strcmp(text, *k) == 0) {
1313-
is_keyword = 1;
1314-
break;
1315-
}
1316-
}
1317-
if (is_keyword && (token->lineno != error_line || token->col_offset != error_col)) {
1329+
Py_ssize_t text_length = token->type == NAME ?
1330+
PyBytes_GET_SIZE(token->bytes) : (Py_ssize_t)strlen(text);
1331+
if (text_length < 2 || text_length > 10) {
13181332
continue;
13191333
}
13201334
const char *alias = NULL;
@@ -1326,14 +1340,14 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
13261340
}
13271341
if (alias != NULL) {
13281342
if (count == MAX_KEYWORD_TYPO_CANDIDATES) {
1329-
return NULL;
1343+
return -1;
13301344
}
13311345
candidates[count++] = (KeywordTypo){i, alias, 0, 1};
13321346
continue;
13331347
}
13341348
PyObject *name = PyUnicode_DecodeUTF8(text, text_length, NULL);
13351349
if (name == NULL) {
1336-
return NULL;
1350+
return -1;
13371351
}
13381352
// Preserve missing-comma diagnostics and correctly spelled keywords
13391353
// unless one edit repairs the input. For keywords, only substitute a
@@ -1358,9 +1372,61 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
13581372
}
13591373
Py_DECREF(name);
13601374
if (result < 0) {
1361-
return NULL;
1375+
return -1;
13621376
}
13631377
}
1378+
return count;
1379+
}
1380+
1381+
static PyObject *
1382+
build_keyword_typo_metadata(Parser *p, const KeywordTypo *candidate)
1383+
{
1384+
Token *token = p->tokens[candidate->mark];
1385+
PyObject *line;
1386+
if (_PyTokenizer_RetainedSource(p->tok) != NULL) {
1387+
Py_ssize_t size;
1388+
const char *text = _PyTokenizer_LineView(p->tok, token->lineno, &size);
1389+
line = PyUnicode_DecodeUTF8(text, size, "replace");
1390+
}
1391+
else {
1392+
_PyTokenizer_Info info = _PyTokenizer_GetInfo(p->tok);
1393+
line = _PyErr_ProgramDecodedTextObject(
1394+
info.filename, token->lineno, info.encoding);
1395+
}
1396+
if (line == NULL) {
1397+
return NULL;
1398+
}
1399+
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1400+
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1401+
PyObject *metadata = NULL;
1402+
if (start >= 0 && end >= 0) {
1403+
metadata = Py_BuildValue("(sOinin)", candidate->keyword, line,
1404+
token->lineno, start + 1,
1405+
token->end_lineno, end + 1);
1406+
}
1407+
Py_DECREF(line);
1408+
return metadata;
1409+
}
1410+
1411+
static PyObject *
1412+
keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
1413+
{
1414+
// A real ENDMARKER is required even for an unfinished suite. A truncated
1415+
// token cache is never evidence that a replacement is valid.
1416+
if ((p->start_rule != Py_file_input && p->start_rule != Py_eval_input &&
1417+
p->start_rule != Py_single_input) ||
1418+
p->fill == 0 || p->fill > MAX_KEYWORD_TYPO_TOKENS ||
1419+
_PyTokenizer_GetInfo(p->tok).input_span.end > MAX_KEYWORD_TYPO_BYTES ||
1420+
p->tokens[p->fill - 1]->type != ENDMARKER ||
1421+
_PyTokenizer_IsInteractive(p->tok))
1422+
{
1423+
return NULL;
1424+
}
1425+
KeywordTypo candidates[MAX_KEYWORD_TYPO_CANDIDATES];
1426+
int count = collect_keyword_typos(p, error, candidates);
1427+
if (count <= 0) {
1428+
return NULL;
1429+
}
13641430
qsort(candidates, count, sizeof(*candidates), compare_keyword_typos);
13651431
// All candidates and both parser passes share this work budget.
13661432
KeywordTrial state = {.budget = MAX_KEYWORD_TYPO_STEPS};
@@ -1369,55 +1435,27 @@ keyword_typo_metadata(Parser *p, PySyntaxErrorObject *error)
13691435
if (accepted < 0) {
13701436
return NULL;
13711437
}
1372-
if (!accepted) {
1373-
continue;
1374-
}
1375-
Token *token = p->tokens[candidates[i].mark];
1376-
PyObject *line;
1377-
if (_PyTokenizer_RetainedSource(p->tok) != NULL) {
1378-
Py_ssize_t size;
1379-
const char *text = _PyTokenizer_LineView(p->tok, token->lineno, &size);
1380-
line = PyUnicode_DecodeUTF8(text, size, "replace");
1381-
}
1382-
else {
1383-
_PyTokenizer_Info info = _PyTokenizer_GetInfo(p->tok);
1384-
line = _PyErr_ProgramDecodedTextObject(
1385-
info.filename, token->lineno, info.encoding);
1438+
if (accepted) {
1439+
return build_keyword_typo_metadata(p, &candidates[i]);
13861440
}
1387-
if (line == NULL) {
1388-
return NULL;
1389-
}
1390-
Py_ssize_t start = _PyPegen_byte_offset_to_character_offset(line, token->col_offset);
1391-
Py_ssize_t end = _PyPegen_byte_offset_to_character_offset(line, token->end_col_offset);
1392-
PyObject *metadata = NULL;
1393-
if (start >= 0 && end >= 0) {
1394-
metadata = Py_BuildValue("(sOinin)", candidates[i].keyword, line,
1395-
token->lineno, start + 1,
1396-
token->end_lineno, end + 1);
1397-
}
1398-
Py_DECREF(line);
1399-
return metadata;
14001441
}
14011442
return NULL;
14021443
}
14031444

14041445
static void
1405-
_PyPegen_set_syntax_error_metadata(Parser *p) {
1446+
_PyPegen_set_syntax_error_metadata(Parser *p)
1447+
{
14061448
PyObject *exc = PyErr_GetRaisedException();
1407-
if (!exc || !PyObject_TypeCheck(exc, (PyTypeObject *)PyExc_SyntaxError)) {
1408-
PyErr_SetRaisedException(exc);
1409-
return;
1410-
}
1411-
PySyntaxErrorObject *syntax_error = (PySyntaxErrorObject *)exc;
1412-
PyObject *hint = keyword_typo_metadata(p, syntax_error);
1413-
if (hint != NULL) {
1414-
Py_XSETREF(syntax_error->metadata, hint);
1415-
PyErr_SetRaisedException(exc);
1416-
return;
1417-
}
1418-
// Suggestions must never replace the original exception, including when
1419-
// a trial runs out of memory or exhausts its rule budget.
1420-
PyErr_Clear();
1449+
if (exc != NULL && Py_IS_TYPE(exc, (PyTypeObject *)PyExc_SyntaxError)) {
1450+
PySyntaxErrorObject *syntax_error = (PySyntaxErrorObject *)exc;
1451+
PyObject *hint = keyword_typo_metadata(p, syntax_error);
1452+
if (hint != NULL) {
1453+
Py_XSETREF(syntax_error->metadata, hint);
1454+
}
1455+
// Suggestions must never replace the original exception, including
1456+
// when a trial runs out of memory or exhausts its rule budget.
1457+
PyErr_Clear();
1458+
}
14211459
PyErr_SetRaisedException(exc);
14221460
}
14231461

‎Parser/string_parser.c‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ static int
1313
warn_invalid_escape_sequence(Parser *p, const char* buffer, const char *first_invalid_escape, Token *t)
1414
{
1515
if (p->call_invalid_rules || p->keyword_trial != NULL) {
16-
// Do not report warnings if we are in the second pass of the parser
17-
// to avoid showing the warning twice.
16+
// Error passes and keyword trials must not repeat parser warnings.
1817
return 0;
1918
}
2019
unsigned char c = (unsigned char)*first_invalid_escape;

0 commit comments

Comments
 (0)