Skip to content

Commit 1d75e17

Browse files
committed
gh-153569: report tokenizer diagnostics without rewinding the scanner
1 parent 2cf51c4 commit 1d75e17

11 files changed

Lines changed: 239 additions & 99 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
@@ -198,6 +198,8 @@ def test_nested_templates(self):
198198

199199
def test_syntax_errors(self):
200200
for case, err in (
201+
('t"""{(\n1\n)}\ntail', "unterminated triple-quoted t-string literal"),
202+
('f"""{(\n1\n)}\ntail', "unterminated triple-quoted f-string literal"),
201203
("t'", "unterminated t-string literal"),
202204
("t'''", "unterminated triple-quoted t-string literal"),
203205
("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
@@ -101,6 +101,7 @@ verify_identifier(struct tok_state *tok)
101101
assert(PyUnicode_GET_LENGTH(s) > 0);
102102
if (invalid < PyUnicode_GET_LENGTH(s)) {
103103
Py_UCS4 ch = PyUnicode_READ_CHAR(s, invalid);
104+
const char *error_cursor = tok->cur;
104105
if (invalid + 1 < PyUnicode_GET_LENGTH(s)) {
105106
/* Determine the offset in UTF-8 encoded input */
106107
Py_SETREF(s, PyUnicode_Substring(s, 0, invalid + 1));
@@ -111,14 +112,20 @@ verify_identifier(struct tok_state *tok)
111112
tok->done = E_ERROR;
112113
return 0;
113114
}
114-
tok->cur = (char *)tok->start + PyBytes_GET_SIZE(s);
115+
error_cursor = tok->start + PyBytes_GET_SIZE(s);
115116
}
116117
Py_DECREF(s);
117118
if (Py_UNICODE_ISPRINTABLE(ch)) {
118-
_PyTokenizer_syntaxerror(tok, "invalid character '%c' (U+%04X)", ch, ch);
119+
_PyTokenizer_syntaxerror_at(
120+
tok, tok->line_start,
121+
error_cursor - tok->line_start, tok->lineno, -1, -1,
122+
"invalid character '%c' (U+%04X)", ch, ch);
119123
}
120124
else {
121-
_PyTokenizer_syntaxerror(tok, "invalid non-printable character U+%04X", ch);
125+
_PyTokenizer_syntaxerror_at(
126+
tok, tok->line_start,
127+
error_cursor - tok->line_start, tok->lineno, -1, -1,
128+
"invalid non-printable character U+%04X", ch);
122129
}
123130
return 0;
124131
}

Parser/lexer/state.h

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

74+
/* Supplemental source context for a terminal error. location is the reporting
75+
cursor, independent of the scanner cursor; lineno == 0 means absent.
76+
The text span may cover multiple physical lines. */
77+
typedef struct {
78+
_PyTok_Loc location;
79+
_PyTok_Span text_span;
80+
} _PyTokenizer_Diagnostic;
81+
7482
/* Tokenizer state */
7583
struct tok_state {
7684
/* Input state; buf <= cur <= inp */
@@ -89,6 +97,7 @@ struct tok_state {
8997
expression (cf. issue 16806) */
9098
int starting_col_offset; /* The column offset at the beginning of a token */
9199
int col_offset; /* Current col offset */
100+
_PyTokenizer_Diagnostic diagnostic;
92101
int level; /* () [] {} Parentheses nesting level */
93102
/* Used to allow free continuations inside them */
94103
char parenstack[MAXLEVEL];
@@ -118,6 +127,63 @@ struct tok_state {
118127
#endif
119128
};
120129

130+
static inline _PyTok_Off
131+
_PyLexer_BufferOffset(const struct tok_state *tok, const char *position)
132+
{
133+
assert(tok->buf != NULL);
134+
assert(tok->inp >= tok->buf);
135+
assert(position >= tok->buf && position <= tok->inp);
136+
Py_ssize_t offset = position - tok->buf;
137+
assert(tok->buf_offset <= PY_SSIZE_T_MAX - offset);
138+
return tok->buf_offset + offset;
139+
}
140+
141+
static inline char *
142+
_PyLexer_BufferPointer(const struct tok_state *tok, _PyTok_Off offset)
143+
{
144+
assert(tok->buf != NULL);
145+
assert(tok->inp >= tok->buf);
146+
assert(offset >= tok->buf_offset);
147+
assert(offset - tok->buf_offset <= tok->inp - tok->buf);
148+
return tok->buf + (offset - tok->buf_offset);
149+
}
150+
151+
static inline const char *
152+
_PyLexer_BufferSpanView(const struct tok_state *tok, _PyTok_Span span,
153+
Py_ssize_t *length)
154+
{
155+
assert(length != NULL);
156+
assert(_PyTok_SpanIsValid(span));
157+
*length = span.end - span.start;
158+
(void)_PyLexer_BufferPointer(tok, span.end);
159+
return _PyLexer_BufferPointer(tok, span.start);
160+
}
161+
162+
static inline int
163+
_PyLexer_ByteColumn(const struct tok_state *tok)
164+
{
165+
assert(tok->line_start != NULL);
166+
assert(tok->cur >= tok->line_start);
167+
Py_ssize_t column = tok->cur - tok->line_start;
168+
assert(column <= INT_MAX);
169+
return (int)column;
170+
}
171+
172+
static inline _PyTok_Span
173+
_PyLexer_BufferSpan(const struct tok_state *tok, const char *start,
174+
const char *end)
175+
{
176+
if (start == NULL) {
177+
assert(end == NULL);
178+
return (_PyTok_Span){-1, -1};
179+
}
180+
assert(end != NULL);
181+
assert(start <= end);
182+
return _PyTok_SpanFromBounds(
183+
_PyLexer_BufferOffset(tok, start),
184+
_PyLexer_BufferOffset(tok, end));
185+
}
186+
121187
int _PyLexer_token_setup(struct tok_state *tok, struct token *token, int type, const char *start, const char *end);
122188

123189
void _PyLexer_ImplyDedents(struct tok_state *);

Parser/lexer/string.c

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@
77

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

10+
static int
11+
string_error_token(struct tok_state *tok, struct token *token,
12+
const char *start, _PyTok_Loc location)
13+
{
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;
22+
}
23+
1024
int
1125
_PyLexer_set_ftstring_expr(struct tok_state* tok, struct token *token, char c) {
1226
assert(token != NULL);
@@ -347,55 +361,52 @@ _PyLexer_scan_string(struct tok_state *tok, struct token *token, int c)
347361
break;
348362
}
349363
if (c == EOF || (quote_size == 1 && c == '\n')) {
350-
assert(tok->multi_line_start != NULL);
351-
// shift the tok_state's location into
352-
// the start of string, and report the error
353-
// from the initial quote character
354-
tok->cur = (char *)tok->start;
355-
tok->cur++;
356-
tok->line_start = tok->multi_line_start;
357-
int start = tok->lineno;
358-
tok->lineno = tok->first_lineno;
359-
360-
if (INSIDE_FSTRING(tok)) {
361-
/* When we are in an f-string, before raising the
362-
* unterminated string literal error, check whether
363-
* does the initial quote matches with f-strings quotes
364-
* and if it is, then this must be a missing '}' token
365-
* so raise the proper error */
366-
tokenizer_mode *the_current_tok = TOK_GET_MODE(tok);
367-
if (the_current_tok->quote == quote &&
368-
the_current_tok->quote_size == quote_size) {
369-
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
370-
"%c-string: expecting '}'", TOK_GET_STRING_PREFIX(tok)));
364+
int end_lineno = tok->lineno;
365+
_PyTok_Loc location = {tok->first_lineno, tok->starting_col_offset};
366+
const char *line = tok->start - location.byte_col;
367+
Py_ssize_t cursor_offset = (Py_ssize_t)location.byte_col + 1;
368+
369+
const tokenizer_mode *state = INSIDE_FSTRING(tok) ? TOK_GET_MODE(tok) : NULL;
370+
if (state != NULL) {
371+
/* A matching quote belongs to the surrounding formatted
372+
* string, so the expression is missing its closing brace. */
373+
if (state->quote == quote && state->quote_size == quote_size) {
374+
_PyTokenizer_syntaxerror_at(
375+
tok, line, cursor_offset, location.lineno, -1, -1,
376+
"%c-string: expecting '}'",
377+
TOK_GET_STRING_PREFIX(tok));
378+
return string_error_token(tok, token, tok->start, location);
371379
}
372380
}
373381

374382
if (quote_size == 3) {
375-
_PyTokenizer_syntaxerror(tok, "unterminated triple-quoted string literal"
376-
" (detected at line %d)", start);
383+
_PyTokenizer_syntaxerror_at(
384+
tok, line, cursor_offset, location.lineno, -1, -1,
385+
"unterminated triple-quoted string literal"
386+
" (detected at line %d)", end_lineno);
377387
if (c != '\n') {
378388
tok->done = E_EOFS;
379389
}
380-
return MAKE_TOKEN(ERRORTOKEN);
390+
return string_error_token(tok, token, tok->start, location);
381391
}
382392
else {
383393
if (has_escaped_quote) {
384-
_PyTokenizer_syntaxerror(
385-
tok,
394+
_PyTokenizer_syntaxerror_at(
395+
tok, line, cursor_offset, location.lineno, -1, -1,
386396
"unterminated string literal (detected at line %d); "
387397
"perhaps you escaped the end quote?",
388-
start
398+
end_lineno
389399
);
390400
} else {
391-
_PyTokenizer_syntaxerror(
392-
tok, "unterminated string literal (detected at line %d)", start
401+
_PyTokenizer_syntaxerror_at(
402+
tok, line, cursor_offset, location.lineno, -1, -1,
403+
"unterminated string literal (detected at line %d)", end_lineno
393404
);
394405
}
395406
if (c != '\n') {
396407
tok->done = E_EOLS;
397408
}
398-
return MAKE_TOKEN(ERRORTOKEN);
409+
return string_error_token(tok, token, tok->start, location);
399410
}
400411
}
401412
if (c == quote) {
@@ -516,32 +527,31 @@ _PyLexer_get_fstring_mode(struct tok_state *tok, tokenizer_mode* current_tok, st
516527
return MAKE_TOKEN(FTSTRING_MIDDLE(current_tok));
517528
}
518529

519-
assert(tok->multi_line_start != NULL);
520-
// shift the tok_state's location into
521-
// the start of string, and report the error
522-
// from the initial quote character
523-
tok->cur = (char *)current_tok->start;
524-
tok->cur++;
525-
tok->line_start = current_tok->multi_line_start;
526-
int start = tok->lineno;
527-
528-
tokenizer_mode *the_current_tok = TOK_GET_MODE(tok);
529-
tok->lineno = the_current_tok->first_line;
530+
int end_lineno = tok->lineno;
531+
_PyTok_Loc location = {current_tok->first_line,
532+
(int)(current_tok->start - current_tok->multi_line_start)};
533+
const char *line = current_tok->multi_line_start;
534+
Py_ssize_t cursor_offset = (Py_ssize_t)location.byte_col + 1;
530535

531536
if (current_tok->quote_size == 3) {
532-
_PyTokenizer_syntaxerror(tok,
533-
"unterminated triple-quoted %c-string literal"
534-
" (detected at line %d)",
535-
TOK_GET_STRING_PREFIX(tok), start);
537+
_PyTokenizer_syntaxerror_at(
538+
tok, line, cursor_offset, location.lineno, -1, -1,
539+
"unterminated triple-quoted %c-string literal"
540+
" (detected at line %d)",
541+
TOK_GET_STRING_PREFIX(tok), end_lineno);
536542
if (c != '\n') {
537543
tok->done = E_EOFS;
538544
}
539-
return MAKE_TOKEN(ERRORTOKEN);
545+
return string_error_token(tok, token,
546+
current_tok->start, location);
540547
}
541548
else {
542-
return MAKE_TOKEN(_PyTokenizer_syntaxerror(tok,
543-
"unterminated %c-string literal (detected at"
544-
" line %d)", TOK_GET_STRING_PREFIX(tok), start));
549+
_PyTokenizer_syntaxerror_at(
550+
tok, line, cursor_offset, location.lineno, -1, -1,
551+
"unterminated %c-string literal (detected at line %d)",
552+
TOK_GET_STRING_PREFIX(tok), end_lineno);
553+
return string_error_token(tok, token,
554+
current_tok->start, location);
545555
}
546556
}
547557

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

0 commit comments

Comments
 (0)