Skip to content

Commit 11383f5

Browse files
committed
gh-153569: report tokenizer diagnostics without rewinding the scanner
1 parent ef8e703 commit 11383f5

11 files changed

Lines changed: 168 additions & 79 deletions

File tree

Lib/test/test_codeop.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,17 @@ def test_valid(self, compiler):
113113
av("def f():\n pass\n#foo\n")
114114
av("@a.b.c\ndef f():\n pass\n")
115115

116+
@subTests('symbol', ('single', 'exec'))
117+
@subTests('prefix', ('', 'f', 't'))
118+
def test_incomplete_string_diagnostics(self, symbol, prefix):
119+
opening = f' á = {prefix}"""first\n'
120+
source = 'if True:\n' + opening + 'second'
121+
with self.assertRaises(_IncompleteInputError) as cm:
122+
Compile()(source, '<input>', symbol)
123+
text = opening + 'second' + ('\n' if symbol == 'exec' else '')
124+
self.assertEqual(cm.exception.args, (
125+
'incomplete input', ('<input>', 2, 9, text, 2, -1)))
126+
116127
@subTests('compiler', COMPILERS)
117128
def test_incomplete(self, compiler):
118129
ai = functools.partial(self.assertIncomplete, compiler=compiler)

Lib/test/test_source_encoding.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import unittest
44
from test import support
55
from test.support import script_helper
6-
from test.support.os_helper import TESTFN, unlink, rmtree
7-
from test.support.import_helper import unload
6+
from test.support.os_helper import TESTFN, TESTFN_ASCII, unlink, rmtree
7+
from test.support.import_helper import import_module, unload
88
import importlib
99
import os
1010
import sys
@@ -83,12 +83,30 @@ def test_truncated_utf8_at_eof(self):
8383
self.assertRaises(SyntaxError, compile, seq, '<test>', 'exec')
8484

8585
def test_invalid_utf8_offset_after_non_ascii(self):
86+
for name in ('é', 'éé', '𝒜'):
87+
with self.subTest(name=name):
88+
source = ('x = ' + name).encode() + b'\xff\n'
89+
with self.assertRaises(SyntaxError) as caught:
90+
compile(source, '<test>', 'exec')
91+
error = caught.exception
92+
self.assertEqual(
93+
(error.lineno, error.offset, error.end_lineno, error.end_offset),
94+
(1, 5 + len(name), 1, 5 + len(name)),
95+
)
96+
97+
@support.cpython_only
98+
def test_invalid_utf8_file_offset_after_non_ascii(self):
99+
_testcapi = import_module('_testcapi')
100+
self.addCleanup(unlink, TESTFN_ASCII)
101+
with open(TESTFN_ASCII, 'wb') as f:
102+
f.write(b'\nx = \xc3\xa9\xc3\xa9\xff\n')
86103
with self.assertRaises(SyntaxError) as caught:
87-
compile(b"x = \xc3\xa9\xff\n", "<test>", "exec")
104+
_testcapi.run_file(
105+
os.fsencode(TESTFN_ASCII), _testcapi.Py_file_input, {})
88106
error = caught.exception
89107
self.assertEqual(
90108
(error.lineno, error.offset, error.end_lineno, error.end_offset),
91-
(1, 6, 1, 6),
109+
(2, 7, 2, 7),
92110
)
93111

94112
def test_long_bom_conflict_message_is_not_truncated(self):

Lib/test/test_tstring.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ def test_nested_templates(self):
207207

208208
def test_syntax_errors(self):
209209
for case, err in (
210+
('t"""{(\n1\n)}\ntail', "unterminated triple-quoted t-string literal"),
211+
('f"""{(\n1\n)}\ntail', "unterminated triple-quoted f-string literal"),
210212
("t'", "unterminated t-string literal"),
211213
("t'''", "unterminated triple-quoted t-string literal"),
212214
("t''''", "unterminated triple-quoted t-string literal"),

Parser/lexer/lexer.c

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ verify_identifier(struct tok_state *tok)
9999
assert(PyUnicode_GET_LENGTH(s) > 0);
100100
if (invalid < PyUnicode_GET_LENGTH(s)) {
101101
Py_UCS4 ch = PyUnicode_READ_CHAR(s, invalid);
102+
const char *error_cursor = tok->cur;
102103
if (invalid + 1 < PyUnicode_GET_LENGTH(s)) {
103104
/* Determine the offset in UTF-8 encoded input */
104105
Py_SETREF(s, PyUnicode_Substring(s, 0, invalid + 1));
@@ -109,14 +110,20 @@ verify_identifier(struct tok_state *tok)
109110
tok->done = E_ERROR;
110111
return 0;
111112
}
112-
tok->cur = (char *)tok->start + PyBytes_GET_SIZE(s);
113+
error_cursor = tok->start + PyBytes_GET_SIZE(s);
113114
}
114115
Py_DECREF(s);
115116
if (Py_UNICODE_ISPRINTABLE(ch)) {
116-
_PyTokenizer_syntaxerror(tok, "invalid character '%c' (U+%04X)", ch, ch);
117+
_PyTokenizer_syntaxerror_at(
118+
tok, tok->line_start,
119+
error_cursor - tok->line_start, tok->lineno, -1, -1,
120+
"invalid character '%c' (U+%04X)", ch, ch);
117121
}
118122
else {
119-
_PyTokenizer_syntaxerror(tok, "invalid non-printable character U+%04X", ch);
123+
_PyTokenizer_syntaxerror_at(
124+
tok, tok->line_start,
125+
error_cursor - tok->line_start, tok->lineno, -1, -1,
126+
"invalid non-printable character U+%04X", ch);
120127
}
121128
return 0;
122129
}

Parser/lexer/state.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ typedef struct {
7777
indentation_level stack[MAXINDENT];
7878
} lexer_layout_state;
7979

80+
/* Supplemental source context for a terminal error. location is the reporting
81+
cursor, independent of the scanner cursor; lineno == 0 means absent.
82+
The text span may cover multiple physical lines. */
83+
typedef struct {
84+
_PyTok_Loc location;
85+
_PyTok_Span text_span;
86+
} _PyTokenizer_Diagnostic;
87+
8088
/* Tokenizer state */
8189
struct tok_state {
8290
/* Input state; buf <= cur <= inp */
@@ -92,6 +100,7 @@ struct tok_state {
92100
lexer_layout_state layout;
93101
int lineno; /* Current line number */
94102
_PyTok_Loc start_loc;
103+
_PyTokenizer_Diagnostic diagnostic;
95104
int level; /* () [] {} Parentheses nesting level */
96105
/* Used to allow free continuations inside them */
97106
char parenstack[MAXLEVEL];

Parser/lexer/string.c

Lines changed: 45 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,18 @@
77

88
#define MAKE_TOKEN(token_type) _PyLexer_token_setup(tok, token, token_type, p_start, p_end)
99

10-
static void
11-
rewind_to_string_start(struct tok_state *tok, const char *start,
12-
_PyTok_Loc location)
10+
static int
11+
string_error_token(struct tok_state *tok, struct token *token,
12+
const char *start, _PyTok_Loc location)
1313
{
14-
tok->cur = (char *)start + 1;
15-
tok->line_start = start - location.byte_col;
16-
tok->lineno = location.lineno;
14+
tok->diagnostic = (_PyTokenizer_Diagnostic){
15+
.location = {location.lineno, location.byte_col + 1},
16+
.text_span = _PyLexer_BufferSpan(tok, start - location.byte_col, tok->inp),
17+
};
18+
int type = _PyLexer_token_setup(tok, token, ERRORTOKEN, NULL, NULL);
19+
token->start_loc = location;
20+
token->end_loc = (_PyTok_Loc){location.lineno, -1};
21+
return type;
1722
}
1823

1924
int
@@ -351,44 +356,51 @@ _PyLexer_scan_string(struct tok_state *tok, struct token *token, int c)
351356
}
352357
if (c == EOF || (quote_size == 1 && c == '\n')) {
353358
int end_lineno = tok->lineno;
354-
rewind_to_string_start(tok, tok->start, tok->start_loc);
359+
_PyTok_Loc location = tok->start_loc;
360+
const char *line = tok->start - location.byte_col;
361+
Py_ssize_t cursor_offset = (Py_ssize_t)location.byte_col + 1;
355362

356363
const ftstring_state *state = _PyLexer_CurrentFTString(tok);
357364
if (state != NULL) {
358365
/* A matching quote belongs to the surrounding formatted
359366
* string, so the expression is missing its closing brace. */
360367
if (state->quote == quote && state->quote_size == quote_size) {
361-
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
368+
_PyTokenizer_syntaxerror_at(
369+
tok, line, cursor_offset, location.lineno, -1, -1,
362370
"%c-string: expecting '}'",
363-
_PyLexer_StringPrefix(state->kind)));
371+
_PyLexer_StringPrefix(state->kind));
372+
return string_error_token(tok, token, tok->start, location);
364373
}
365374
}
366375

367376
if (quote_size == 3) {
368-
_PyTokenizer_syntaxerror(tok, "unterminated triple-quoted string literal"
369-
" (detected at line %d)", end_lineno);
377+
_PyTokenizer_syntaxerror_at(
378+
tok, line, cursor_offset, location.lineno, -1, -1,
379+
"unterminated triple-quoted string literal"
380+
" (detected at line %d)", end_lineno);
370381
if (c != '\n') {
371382
tok->done = E_EOFS;
372383
}
373-
return MAKE_TOKEN(ERRORTOKEN);
384+
return string_error_token(tok, token, tok->start, location);
374385
}
375386
else {
376387
if (has_escaped_quote) {
377-
_PyTokenizer_syntaxerror(
378-
tok,
388+
_PyTokenizer_syntaxerror_at(
389+
tok, line, cursor_offset, location.lineno, -1, -1,
379390
"unterminated string literal (detected at line %d); "
380391
"perhaps you escaped the end quote?",
381392
end_lineno
382393
);
383394
} else {
384-
_PyTokenizer_syntaxerror(
385-
tok, "unterminated string literal (detected at line %d)", end_lineno
395+
_PyTokenizer_syntaxerror_at(
396+
tok, line, cursor_offset, location.lineno, -1, -1,
397+
"unterminated string literal (detected at line %d)", end_lineno
386398
);
387399
}
388400
if (c != '\n') {
389401
tok->done = E_EOLS;
390402
}
391-
return MAKE_TOKEN(ERRORTOKEN);
403+
return string_error_token(tok, token, tok->start, location);
392404
}
393405
}
394406
if (c == quote) {
@@ -451,25 +463,29 @@ _PyLexer_get_ftstring(struct tok_state *tok, ftstring_state *current, struct tok
451463
}
452464

453465
int end_lineno = tok->lineno;
454-
rewind_to_string_start(tok,
455-
_PyLexer_BufferPointer(tok, current->start),
456-
current->start_loc);
466+
_PyTok_Loc location = current->start_loc;
467+
const char *line = _PyLexer_BufferPointer(tok, current->start) - location.byte_col;
468+
Py_ssize_t cursor_offset = (Py_ssize_t)location.byte_col + 1;
457469

458470
if (quote_size == 3) {
459-
_PyTokenizer_syntaxerror(tok,
460-
"unterminated triple-quoted %c-string literal"
461-
" (detected at line %d)",
462-
_PyLexer_StringPrefix(current->kind), end_lineno);
471+
_PyTokenizer_syntaxerror_at(
472+
tok, line, cursor_offset, location.lineno, -1, -1,
473+
"unterminated triple-quoted %c-string literal"
474+
" (detected at line %d)",
475+
_PyLexer_StringPrefix(current->kind), end_lineno);
463476
if (c != '\n') {
464477
tok->done = E_EOFS;
465478
}
466-
return MAKE_TOKEN(ERRORTOKEN);
479+
return string_error_token(tok, token,
480+
_PyLexer_BufferPointer(tok, current->start), location);
467481
}
468482
else {
469-
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
470-
"unterminated %c-string literal (detected at"
471-
" line %d)",
472-
_PyLexer_StringPrefix(current->kind), end_lineno));
483+
_PyTokenizer_syntaxerror_at(
484+
tok, line, cursor_offset, location.lineno, -1, -1,
485+
"unterminated %c-string literal (detected at line %d)",
486+
_PyLexer_StringPrefix(current->kind), end_lineno);
487+
return string_error_token(tok, token,
488+
_PyLexer_BufferPointer(tok, current->start), location);
473489
}
474490
}
475491

Parser/pegen.c

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,11 +218,11 @@ initialize_token(Parser *p, Token *parser_token, struct token *new_token, int to
218218

219219
parser_token->level = new_token->level;
220220
parser_token->lineno = new_token->start_loc.lineno;
221-
parser_token->col_offset = p->tok->lineno == p->starting_lineno
221+
parser_token->col_offset = new_token->end_loc.lineno == p->starting_lineno
222222
? p->starting_col_offset + new_token->start_loc.byte_col
223223
: new_token->start_loc.byte_col;
224224
parser_token->end_lineno = new_token->end_loc.lineno;
225-
parser_token->end_col_offset = p->tok->lineno == p->starting_lineno
225+
parser_token->end_col_offset = new_token->end_loc.lineno == p->starting_lineno
226226
? p->starting_col_offset + new_token->end_loc.byte_col
227227
: new_token->end_loc.byte_col;
228228

Parser/pegen_errors.c

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,10 @@ _PyPegen_raise_error(Parser *p, PyObject *errtype, int use_mark, const char *err
202202
Py_ssize_t col_offset;
203203
Py_ssize_t end_col_offset = -1;
204204
if (t->col_offset == -1) {
205-
if (p->tok->cur == p->tok->buf) {
205+
_PyTokenizer_Diagnostic diagnostic = p->tok->diagnostic;
206+
if (diagnostic.location.lineno != 0) {
207+
col_offset = diagnostic.location.byte_col;
208+
} else if (p->tok->cur == p->tok->buf) {
206209
col_offset = 0;
207210
} else {
208211
const char* start = p->tok->buf ? p->tok->line_start : p->tok->buf;
@@ -251,21 +254,27 @@ _PyPegen_raise_error_known_location(Parser *p, PyObject *errtype,
251254
PyObject *error_line = NULL;
252255
PyObject *tmp = NULL;
253256
p->error_indicator = 1;
257+
_PyTokenizer_Diagnostic diagnostic = p->tok->diagnostic;
258+
_PyTok_Loc location = diagnostic.location.lineno != 0
259+
? diagnostic.location : (_PyTok_Loc){p->tok->lineno,
260+
p->tok->line_start == NULL ? -1 : _PyLexer_ByteColumn(p->tok)};
261+
_PyTok_Span text_span = diagnostic.location.lineno != 0
262+
? diagnostic.text_span : _PyLexer_BufferSpan(
263+
p->tok, p->tok->line_start, p->tok->inp);
254264

255265
if (end_lineno == CURRENT_POS) {
256-
end_lineno = p->tok->lineno;
266+
end_lineno = location.lineno;
257267
}
258268
if (end_col_offset == CURRENT_POS) {
259-
end_col_offset = p->tok->cur - p->tok->line_start;
269+
end_col_offset = location.byte_col;
260270
}
261271

262272
errstr = PyUnicode_FromFormatV(errmsg, va);
263273
if (!errstr) {
264274
goto error;
265275
}
266276

267-
if (_PyTok_ReaderIsInteractive(p->tok) &&
268-
_PyTokenizer_RetainedSource(p->tok) != NULL) {
277+
if (_PyTok_ReaderIsInteractive(p->tok) && _PyTokenizer_RetainedSource(p->tok) != NULL) {
269278
error_line = get_error_line_from_source(p, lineno);
270279
}
271280
else if (p->start_rule == Py_file_input) {
@@ -281,13 +290,16 @@ _PyPegen_raise_error_known_location(Parser *p, PyObject *errtype,
281290
we're actually parsing from a file, which has an E_EOF SyntaxError and in that case
282291
`PyErr_ProgramTextObject` fails because lineno points to last_file_line + 1, which
283292
does not physically exist */
284-
assert(p->tok->fp == NULL || p->tok->fp == stdin || p->tok->done == E_EOF);
285-
286-
if (p->tok->lineno <= lineno && p->tok->inp > p->tok->buf) {
287-
Py_ssize_t size = p->tok->inp - p->tok->line_start;
288-
error_line = PyUnicode_DecodeUTF8(p->tok->line_start, size, "replace");
293+
assert((p->tok->fp == NULL || p->tok->fp == stdin) || p->tok->done == E_EOF);
294+
295+
if (location.lineno <= lineno &&
296+
p->tok->inp > p->tok->buf) {
297+
Py_ssize_t size;
298+
const char *line = _PyLexer_BufferSpanView(
299+
p->tok, text_span, &size);
300+
error_line = PyUnicode_DecodeUTF8(line, size, "replace");
289301
}
290-
else if (p->tok->fp == NULL || p->tok->fp == stdin) {
302+
else if ((p->tok->fp == NULL || p->tok->fp == stdin)) {
291303
error_line = get_error_line_from_source(p, lineno);
292304
}
293305
else {

Parser/tokenizer/decoder.c

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -240,22 +240,14 @@ _PyTok_DetectEncoding(struct tok_state *tok, const _PyTok_Chunk *first,
240240
const _PyTok_Chunk *line = cookie_line == 2 ? second : first;
241241
const char *line_data = line->data + (cookie_line == 1 ? 3 : 0);
242242
Py_ssize_t line_len = line->len - (cookie_line == 1 ? 3 : 0);
243-
const char *saved_line_start = tok->line_start;
244-
char *saved_cur = tok->cur;
245-
int saved_lineno = tok->lineno;
246-
tok->line_start = line_data;
247-
tok->cur = (char *)line_data;
248-
tok->lineno = cookie_line;
249243
int end_col = (int)Py_MIN(line_len, INT_MAX);
250244
if (end_col > 0 && (line_data[end_col - 1] == '\n' ||
251245
line_data[end_col - 1] == '\r')) {
252246
end_col--;
253247
}
254-
_PyTokenizer_syntaxerror_known_range(
255-
tok, 0, end_col, "encoding problem: %s with BOM", cookie);
256-
tok->line_start = saved_line_start;
257-
tok->cur = saved_cur;
258-
tok->lineno = saved_lineno;
248+
_PyTokenizer_syntaxerror_at(
249+
tok, line_data, 0, cookie_line, 0, end_col,
250+
"encoding problem: %s with BOM", cookie);
259251
PyMem_Free(cookie);
260252
return _PYTOK_ENCODING_ERROR;
261253
}

0 commit comments

Comments
 (0)