Skip to content

Commit 029775f

Browse files
committed
gh-153569: report tokenizer diagnostics without rewinding the scanner
1 parent 65c1e22 commit 029775f

9 files changed

Lines changed: 111 additions & 45 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
@@ -215,6 +215,8 @@ def test_nested_templates(self):
215215

216216
def test_syntax_errors(self):
217217
for case, err in (
218+
('t"""{(\n1\n)}\ntail', "unterminated triple-quoted t-string literal"),
219+
('f"""{(\n1\n)}\ntail', "unterminated triple-quoted f-string literal"),
218220
("t'", "unterminated t-string literal"),
219221
("t'''", "unterminated triple-quoted t-string literal"),
220222
("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
@@ -89,6 +89,7 @@ verify_identifier(struct tok_state *tok)
8989
assert(PyUnicode_GET_LENGTH(s) > 0);
9090
if (invalid < PyUnicode_GET_LENGTH(s)) {
9191
Py_UCS4 ch = PyUnicode_READ_CHAR(s, invalid);
92+
const char *error_cursor = tok->cur;
9293
if (invalid + 1 < PyUnicode_GET_LENGTH(s)) {
9394
/* Determine the offset in UTF-8 encoded input */
9495
Py_SETREF(s, PyUnicode_Substring(s, 0, invalid + 1));
@@ -99,14 +100,20 @@ verify_identifier(struct tok_state *tok)
99100
tok->done = E_ERROR;
100101
return 0;
101102
}
102-
tok->cur = tok->start + PyBytes_GET_SIZE(s);
103+
error_cursor = tok->start + PyBytes_GET_SIZE(s);
103104
}
104105
Py_DECREF(s);
105106
if (Py_UNICODE_ISPRINTABLE(ch)) {
106-
_PyTokenizer_syntaxerror(tok, "invalid character '%c' (U+%04X)", ch, ch);
107+
_PyTokenizer_syntaxerror_at(
108+
tok, tok->line_start,
109+
error_cursor - tok->line_start, tok->lineno, -1, -1,
110+
"invalid character '%c' (U+%04X)", ch, ch);
107111
}
108112
else {
109-
_PyTokenizer_syntaxerror(tok, "invalid non-printable character U+%04X", ch);
113+
_PyTokenizer_syntaxerror_at(
114+
tok, tok->line_start,
115+
error_cursor - tok->line_start, tok->lineno, -1, -1,
116+
"invalid non-printable character U+%04X", ch);
110117
}
111118
return 0;
112119
}

Parser/lexer/state.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ typedef struct {
7272
indentation_level stack[MAXINDENT];
7373
} lexer_layout_state;
7474

75+
/* Supplemental source context for a terminal error. location is the reporting
76+
cursor, independent of the scanner cursor; lineno == 0 means absent.
77+
The text span may cover multiple physical lines. */
78+
typedef struct {
79+
_PyTok_Loc location;
80+
_PyTok_Span text_span;
81+
} _PyTokenizer_Diagnostic;
82+
7583
/* Tokenizer state */
7684
struct tok_state {
7785
_PyTok_Off buf_offset;
@@ -86,6 +94,7 @@ struct tok_state {
8694
lexer_layout_state layout;
8795
int lineno; /* Current line number */
8896
_PyTok_Loc start_loc;
97+
_PyTokenizer_Diagnostic diagnostic;
8998
int level; /* () [] {} Parentheses nesting level */
9099
/* Used to allow free continuations inside them */
91100
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, _PyTok_Off start,
12-
_PyTok_Loc location)
10+
static int
11+
string_error_token(struct tok_state *tok, struct token *token,
12+
_PyTok_Off start, _PyTok_Loc location)
1313
{
14-
tok->cur = 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 = _PyTok_SpanFromBounds(start - location.byte_col, tok->inp),
17+
};
18+
int type = _PyLexer_token_setup(tok, token, ERRORTOKEN, -1, -1);
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) {
@@ -452,25 +464,29 @@ _PyLexer_get_ftstring(struct tok_state *tok, ftstring_state *current, struct tok
452464
}
453465

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

459471
if (quote_size == 3) {
460-
_PyTokenizer_syntaxerror(tok,
461-
"unterminated triple-quoted %c-string literal"
462-
" (detected at line %d)",
463-
_PyLexer_StringPrefix(current->kind), end_lineno);
472+
_PyTokenizer_syntaxerror_at(
473+
tok, line, cursor_offset, location.lineno, -1, -1,
474+
"unterminated triple-quoted %c-string literal"
475+
" (detected at line %d)",
476+
_PyLexer_StringPrefix(current->kind), end_lineno);
464477
if (c != '\n') {
465478
tok->done = E_EOFS;
466479
}
467-
return MAKE_TOKEN(ERRORTOKEN);
480+
return string_error_token(tok, token,
481+
_PyLexer_BufferPointer(tok, current->start), location);
468482
}
469483
else {
470-
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
471-
"unterminated %c-string literal (detected at"
472-
" line %d)",
473-
_PyLexer_StringPrefix(current->kind), end_lineno));
484+
_PyTokenizer_syntaxerror_at(
485+
tok, line, cursor_offset, location.lineno, -1, -1,
486+
"unterminated %c-string literal (detected at line %d)",
487+
_PyLexer_StringPrefix(current->kind), end_lineno);
488+
return string_error_token(tok, token,
489+
_PyLexer_BufferPointer(tok, current->start), location);
474490
}
475491
}
476492

Parser/tokenizer/decoder.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,8 @@ _PyTok_DetectEncoding(struct tok_state *tok, const _PyTok_Chunk *first,
246246
end_col--;
247247
}
248248
_PyTokenizer_syntaxerror_at(
249-
tok, line_data, 0, cookie_line, 0, end_col, "encoding problem: %s with BOM", cookie);
249+
tok, line_data, 0, cookie_line, 0, end_col,
250+
"encoding problem: %s with BOM", cookie);
250251
PyMem_Free(cookie);
251252
return _PYTOK_ENCODING_ERROR;
252253
}

Parser/tokenizer/helpers.c

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -321,26 +321,21 @@ _PyTokenizer_ensure_utf8(const char *line, struct tok_state *tok, int lineno)
321321
const char *badchar = NULL;
322322
const char *c;
323323
int length;
324-
int col_offset = 0;
325324
const char *line_start = line;
326325
for (c = line; *c; c += length) {
327326
if (!(length = valid_utf8((const unsigned char *)c))) {
328327
badchar = c;
329328
break;
330329
}
331-
col_offset++;
332330
if (*c == '\n') {
333331
lineno++;
334-
col_offset = 0;
335332
line_start = c + 1;
336333
}
337334
}
338335
if (badchar) {
339-
tok->lineno = lineno;
340-
tok->line_start = _PyLexer_BufferOffset(tok, line_start);
341-
tok->cur = _PyLexer_BufferOffset(tok, badchar);
342-
_PyTokenizer_syntaxerror_known_range(tok,
343-
col_offset + 1, col_offset + 1,
336+
_PyTokenizer_syntaxerror_at(
337+
tok, line_start, badchar - line_start + 1, lineno,
338+
-1, -1,
344339
"Non-UTF-8 code starting with '\\x%.2x'"
345340
"%s%V on line %i, "
346341
"but no encoding declared; "

Parser/tokenizer/helpers.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,17 @@
88
int _PyTokenizer_syntaxerror_at(struct tok_state *, const char *,
99
Py_ssize_t, int, int, int, const char *, ...);
1010
int _PyTokenizer_syntaxerror(struct tok_state *tok, const char *format, ...);
11+
/* Positive range columns are 1-based byte columns. A start column of -1
12+
derives the character column from the reporting cursor; an end column of
13+
-1 uses the start column. */
1114
int _PyTokenizer_syntaxerror_known_range(struct tok_state *tok, int col_offset, int end_col_offset, const char *format, ...);
15+
int _PyTokenizer_syntaxerror_at(
16+
struct tok_state *tok, const char *line_start, Py_ssize_t cursor_offset,
17+
int lineno, int col_offset, int end_col_offset, const char *format, ...);
1218
int _PyTokenizer_indenterror(struct tok_state *tok);
1319
int _PyTokenizer_warn_invalid_escape_sequence(struct tok_state *tok, int first_invalid_escape_char);
1420
int _PyTokenizer_parser_warn(struct tok_state *tok, PyObject *category, const char *format, ...);
21+
1522
void _PyTokenizer_raise_init_error(PyObject *filename);
1623

1724
int _PyTokenizer_ensure_utf8(const char *line, struct tok_state *tok, int lineno);

0 commit comments

Comments
 (0)