mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 18:59:04 +08:00
Classify incomplete input from tokenizer EOF flags (#8703)
* Classify incomplete input from tokenizer EOF flags Treat exec's implicit newline, single-quoted f/t-strings, blank single input, and unclosed field braces the way E_EOF, E_EOFS, and E_EOLS do. Assisted-by: Grok:4.6 * address review: lambda defaults, unicode prefixes, comments Keep lambda parameter defaults and Unicode identifiers continuable, and report comment-only single input as SyntaxError. Assisted-by: Grok:4.6
This commit is contained in:
1 parent
6fd55a40a5
commit
b6fc810651
3 files changed
+277
-25
No files matched your search
+147
-13
@@ -89,7 +89,7 @@ impl CompileError {
|
||||
location,
|
||||
end_location,
|
||||
source_path: source_file.name().to_owned(),
|
||||
is_unclosed_bracket: false,
|
||||
is_unclosed_bracket: diagnostic.is_unclosed_bracket,
|
||||
is_unclosed_string: diagnostic.is_unclosed_string,
|
||||
})
|
||||
}
|
||||
@@ -194,6 +194,7 @@ impl NormalizedParseDiagnostic {
|
||||
end_location,
|
||||
);
|
||||
diagnostic_out.is_unclosed_string = diagnostic.is_unclosed_string;
|
||||
diagnostic_out.is_unclosed_bracket = diagnostic.is_unclosed_bracket;
|
||||
diagnostic_out
|
||||
}
|
||||
|
||||
@@ -211,6 +212,7 @@ struct CpythonDiagnostic {
|
||||
message: String,
|
||||
range: ruff_text_size::TextRange,
|
||||
is_unclosed_string: bool,
|
||||
is_unclosed_bracket: bool,
|
||||
}
|
||||
|
||||
impl CpythonDiagnostic {
|
||||
@@ -226,6 +228,7 @@ impl CpythonDiagnostic {
|
||||
TextSize::new(end as u32),
|
||||
),
|
||||
is_unclosed_string: false,
|
||||
is_unclosed_bracket: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +236,11 @@ impl CpythonDiagnostic {
|
||||
self.is_unclosed_string = true;
|
||||
self
|
||||
}
|
||||
|
||||
const fn with_unclosed_bracket(mut self) -> Self {
|
||||
self.is_unclosed_bracket = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn cpython_parse_diagnostic_override(
|
||||
@@ -269,9 +277,11 @@ fn cpython_parse_diagnostic_override(
|
||||
&error.error,
|
||||
parser::ParseErrorType::Lexical(parser::LexicalErrorType::LineContinuationError)
|
||||
) {
|
||||
// Only a backslash at the end of the source is an EOF error.
|
||||
// exec input gets an implicit trailing newline, so a final `\` is a
|
||||
// continuation that then hits EOF (`E_EOF`). single/eval see `\` at
|
||||
// EOF as `E_LINECONT` instead.
|
||||
let terminal_backslash = source_text.len().checked_sub(1);
|
||||
if !matches!(mode, Mode::Eval)
|
||||
if matches!(mode, Mode::Exec)
|
||||
&& terminal_backslash == Some(error.location.start().to_usize())
|
||||
{
|
||||
let loc = source_line_end_location(source_file, error.location.start());
|
||||
@@ -289,7 +299,7 @@ fn cpython_parse_diagnostic_override(
|
||||
));
|
||||
}
|
||||
|
||||
source_error!(unterminated_string_error(source_text));
|
||||
source_error!(unterminated_string_error(source_text, mode));
|
||||
source_error!(expected_indented_block_error(error, source_text));
|
||||
|
||||
if matches!(
|
||||
@@ -494,6 +504,23 @@ fn is_ascii_identifier_char(byte: u8) -> bool {
|
||||
byte == b'_' || byte.is_ascii_alphanumeric()
|
||||
}
|
||||
|
||||
fn identifier_continue_before(bytes: &[u8], index: usize) -> bool {
|
||||
if index == 0 {
|
||||
return false;
|
||||
}
|
||||
if bytes[index - 1].is_ascii() {
|
||||
return is_ascii_identifier_char(bytes[index - 1]);
|
||||
}
|
||||
let mut start = index - 1;
|
||||
while start > 0 && bytes[start] & 0b1100_0000 == 0b1000_0000 {
|
||||
start -= 1;
|
||||
}
|
||||
::core::str::from_utf8(&bytes[start..index])
|
||||
.ok()
|
||||
.and_then(|text| text.chars().next_back())
|
||||
.is_some_and(|ch| ch == '_' || ch.is_alphanumeric())
|
||||
}
|
||||
|
||||
fn numeric_keyword_suffix(rest: &[u8]) -> bool {
|
||||
rest.starts_with(b"and")
|
||||
|| rest.starts_with(b"else")
|
||||
@@ -3894,7 +3921,7 @@ fn non_printable_character_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
None
|
||||
}
|
||||
|
||||
fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
fn unterminated_string_error(source: &str, mode: Mode) -> Option<CpythonDiagnostic> {
|
||||
let bytes = source.as_bytes();
|
||||
let mut index = 0;
|
||||
let mut line = 1usize;
|
||||
@@ -3921,6 +3948,7 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
};
|
||||
index += quote_size;
|
||||
let mut has_escaped_quote = false;
|
||||
let mut ended_with_escape = false;
|
||||
let mut closed = false;
|
||||
while index < bytes.len() {
|
||||
let c = bytes[index];
|
||||
@@ -3952,6 +3980,7 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
if bytes.get(index + 1) == Some("e) {
|
||||
has_escaped_quote = true;
|
||||
}
|
||||
ended_with_escape = index + 1 >= bytes.len();
|
||||
index = (index + 2).min(bytes.len());
|
||||
} else {
|
||||
index += 1;
|
||||
@@ -3964,17 +3993,30 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
return Some(error);
|
||||
}
|
||||
let detected_line = if quote_size == 3 { line } else { start_line };
|
||||
let interpolated = interpolated_string_prefix(bytes, start);
|
||||
let diagnostic = CpythonDiagnostic::new(
|
||||
unterminated_string_message(
|
||||
detected_line,
|
||||
quote_size == 3,
|
||||
has_escaped_quote,
|
||||
interpolated_string_prefix(bytes, start),
|
||||
interpolated,
|
||||
),
|
||||
start,
|
||||
start,
|
||||
);
|
||||
return Some(if index >= bytes.len() {
|
||||
// E_EOLS is only for a plain single-quoted string at EOF.
|
||||
// Single-quoted f/t-strings never set it. exec input appends a
|
||||
// newline, so a single-quoted literal becomes the newline case
|
||||
// unless a final `\` consumes that newline.
|
||||
let exec_implicit_newline =
|
||||
matches!(mode, Mode::Exec) && quote_size == 1 && !ended_with_escape;
|
||||
let eval_assignment =
|
||||
matches!(mode, Mode::Eval) && eval_has_assignment_before(bytes, start);
|
||||
let continuable = index >= bytes.len()
|
||||
&& !(interpolated.is_some() && quote_size == 1)
|
||||
&& !exec_implicit_newline
|
||||
&& !eval_assignment;
|
||||
return Some(if continuable {
|
||||
diagnostic.with_unclosed_string()
|
||||
} else {
|
||||
diagnostic
|
||||
@@ -3987,6 +4029,50 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
None
|
||||
}
|
||||
|
||||
fn eval_has_assignment_before(bytes: &[u8], end: usize) -> bool {
|
||||
let mut index = 0;
|
||||
let mut level = 0usize;
|
||||
let mut in_lambda_params = false;
|
||||
while index < end {
|
||||
match bytes[index] {
|
||||
b'#' => {
|
||||
while index < end && bytes[index] != b'\n' {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
b'\'' | b'"' => index = skip_quoted_string(bytes, index),
|
||||
b'(' | b'[' | b'{' => {
|
||||
level += 1;
|
||||
index += 1;
|
||||
}
|
||||
b')' | b']' | b'}' => {
|
||||
level = level.saturating_sub(1);
|
||||
index += 1;
|
||||
}
|
||||
b':' if level == 0 => {
|
||||
in_lambda_params = false;
|
||||
index += 1;
|
||||
}
|
||||
b'=' if level == 0
|
||||
&& !in_lambda_params
|
||||
&& bytes.get(index + 1) != Some(&b'=')
|
||||
&& !matches!(
|
||||
bytes.get(index.saturating_sub(1)),
|
||||
Some(b':' | b'!' | b'<' | b'>')
|
||||
) =>
|
||||
{
|
||||
return true;
|
||||
}
|
||||
_ if level == 0 && starts_identifier(bytes, index, b"lambda") => {
|
||||
in_lambda_params = true;
|
||||
index += b"lambda".len();
|
||||
}
|
||||
_ => index += 1,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn invalid_interpolated_string_error(source: &str) -> Option<CpythonDiagnostic> {
|
||||
let bytes = source.as_bytes();
|
||||
let mut index = 0;
|
||||
@@ -4161,7 +4247,7 @@ fn interpolated_string_prefix(bytes: &[u8], quote: usize) -> Option<&'static str
|
||||
return None;
|
||||
};
|
||||
|
||||
if prefix_start > 0 && is_ascii_identifier_char(bytes[prefix_start - 1]) {
|
||||
if prefix_start > 0 && identifier_continue_before(bytes, prefix_start) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -4424,11 +4510,10 @@ fn replacement_field_comment_error(
|
||||
}
|
||||
b'#' => {
|
||||
if !bytes[index..literal_end].contains(&b'\n') {
|
||||
return Some(CpythonDiagnostic::new(
|
||||
"'{' was never closed".to_owned(),
|
||||
open,
|
||||
open + 1,
|
||||
));
|
||||
return Some(
|
||||
CpythonDiagnostic::new("'{' was never closed".to_owned(), open, open + 1)
|
||||
.with_unclosed_bracket(),
|
||||
);
|
||||
}
|
||||
index = skip_replacement_field_comment(bytes, index, end);
|
||||
continue;
|
||||
@@ -5482,6 +5567,7 @@ fn unclosed_replacement_field_error(
|
||||
position,
|
||||
position + 1,
|
||||
)
|
||||
.with_unclosed_bracket()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6173,6 +6259,49 @@ fn invalid_unparenthesized_yield_after_comma_error(source: &str) -> Option<Cpyth
|
||||
None
|
||||
}
|
||||
|
||||
/// Horizontal and vertical space the tokenizer skips, not `str::trim()`.
|
||||
const fn is_ascii_tokenizer_whitespace(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0c')
|
||||
}
|
||||
|
||||
/// True when every line is empty or a comment after tokenizer whitespace.
|
||||
#[doc(hidden)]
|
||||
#[must_use]
|
||||
pub fn is_blank_python_source(source: &str) -> bool {
|
||||
source.lines().all(|line| {
|
||||
let trimmed = line.trim_matches(is_ascii_tokenizer_whitespace);
|
||||
trimmed.is_empty() || trimmed.starts_with('#')
|
||||
})
|
||||
}
|
||||
|
||||
fn single_mode_blank_source_error(source_file: &SourceFile) -> Option<CompileError> {
|
||||
let source = source_file.source_text();
|
||||
if !is_blank_python_source(source) {
|
||||
return None;
|
||||
}
|
||||
let has_indent_only_line = source.lines().any(|line| {
|
||||
line.trim_matches(is_ascii_tokenizer_whitespace).is_empty()
|
||||
&& line.chars().any(|c| matches!(c, ' ' | '\t'))
|
||||
});
|
||||
if has_indent_only_line {
|
||||
let (location, end_location) =
|
||||
source_locations(source_file, TextSize::new(0), TextSize::new(0));
|
||||
return Some(CompileError::Parse(ParseError {
|
||||
error: parser::ParseErrorType::UnexpectedIndentation,
|
||||
raw_location: ruff_text_size::TextRange::new(TextSize::new(0), TextSize::new(0)),
|
||||
location,
|
||||
end_location,
|
||||
source_path: source_file.name().to_owned(),
|
||||
is_unclosed_bracket: false,
|
||||
is_unclosed_string: false,
|
||||
}));
|
||||
}
|
||||
Some(CompileError::from_source_error(
|
||||
source_file,
|
||||
CpythonDiagnostic::new("invalid syntax".to_owned(), 0, 0),
|
||||
))
|
||||
}
|
||||
|
||||
/// The byte-order mark is only stripped while decoding source bytes, so one
|
||||
/// that survives into the text is just a non-printable character. The tokenizer
|
||||
/// rejects it everywhere except at the very start of the text, which is where
|
||||
@@ -6590,6 +6719,11 @@ fn _compile_with_syntax_warning_handler<'a>(
|
||||
}
|
||||
let parsed =
|
||||
parsed.map_err(|err| CompileError::from_ruff_parse_error(err, &source_file, mode))?;
|
||||
if matches!(mode, Mode::Single)
|
||||
&& let Some(error) = single_mode_blank_source_error(&source_file)
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
if opts.dont_imply_dedent
|
||||
&& matches!(mode, Mode::Single)
|
||||
&& let Some(error) = dont_imply_dedent_source_error(&source_file)
|
||||
|
||||
+55
-12
@@ -9,7 +9,7 @@ use rustpython_compiler_core::SourceLocation;
|
||||
use core::ops::RangeInclusive;
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
use rustpython_compiler::{CompileError, ParseError};
|
||||
use rustpython_compiler::{CompileError, ParseError, is_blank_python_source};
|
||||
|
||||
use crate::{
|
||||
AsObject, Py, PyObject, PyObjectRef, PyPayload, PyRef, PyResult,
|
||||
@@ -846,9 +846,12 @@ impl VirtualMachine {
|
||||
self.ctx.exceptions.indentation_error
|
||||
}
|
||||
} else if allow_incomplete
|
||||
&& source.is_some_and(|source| {
|
||||
raw_location.end().to_usize() >= source.len() && !source.ends_with('\n')
|
||||
})
|
||||
&& (s == "incomplete input"
|
||||
|| (s == "unexpected EOF while parsing"
|
||||
&& source.is_some_and(|source| {
|
||||
raw_location.end().to_usize() >= source.len()
|
||||
&& !source.ends_with('\n')
|
||||
})))
|
||||
{
|
||||
self.ctx.exceptions.incomplete_input_error
|
||||
} else {
|
||||
@@ -1145,13 +1148,6 @@ impl VirtualMachine {
|
||||
define_exception_fn!(fn new_unbound_local_error, unbound_local_error, UnboundLocalError);
|
||||
}
|
||||
|
||||
fn is_blank_python_source(source: &str) -> bool {
|
||||
source.lines().all(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.is_empty() || trimmed.starts_with('#')
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
enum QuotedStringScan {
|
||||
Closed(usize),
|
||||
@@ -1179,7 +1175,13 @@ fn unclosed_string_is_incomplete(source: &str) -> bool {
|
||||
QuotedStringScan::Unclosed {
|
||||
triple,
|
||||
unescaped_newline,
|
||||
} => return triple || !unescaped_newline,
|
||||
} => {
|
||||
// Single-quoted f/t-strings never set E_EOLS.
|
||||
if !triple && interpolated_string_prefix_at(bytes, index) {
|
||||
return false;
|
||||
}
|
||||
return triple || !unescaped_newline;
|
||||
}
|
||||
},
|
||||
_ => index += 1,
|
||||
}
|
||||
@@ -1187,6 +1189,47 @@ fn unclosed_string_is_incomplete(source: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
fn interpolated_string_prefix_at(bytes: &[u8], quote: usize) -> bool {
|
||||
let Some(&prev) = quote.checked_sub(1).and_then(|index| bytes.get(index)) else {
|
||||
return false;
|
||||
};
|
||||
let lower = prev.to_ascii_lowercase();
|
||||
let marker_index = if matches!(lower, b'f' | b't') {
|
||||
if quote >= 2 && bytes[quote - 2].eq_ignore_ascii_case(&b'r') {
|
||||
quote - 2
|
||||
} else {
|
||||
quote - 1
|
||||
}
|
||||
} else if lower == b'r'
|
||||
&& quote >= 2
|
||||
&& matches!(bytes[quote - 2].to_ascii_lowercase(), b'f' | b't')
|
||||
{
|
||||
quote - 2
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
marker_index == 0 || !identifier_continue_before(bytes, marker_index)
|
||||
}
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
fn identifier_continue_before(bytes: &[u8], index: usize) -> bool {
|
||||
if index == 0 {
|
||||
return false;
|
||||
}
|
||||
if bytes[index - 1].is_ascii() {
|
||||
return bytes[index - 1] == b'_' || bytes[index - 1].is_ascii_alphanumeric();
|
||||
}
|
||||
let mut start = index - 1;
|
||||
while start > 0 && bytes[start] & 0b1100_0000 == 0b1000_0000 {
|
||||
start -= 1;
|
||||
}
|
||||
::core::str::from_utf8(&bytes[start..index])
|
||||
.ok()
|
||||
.and_then(|text| text.chars().next_back())
|
||||
.is_some_and(|ch| ch == '_' || ch.is_alphanumeric())
|
||||
}
|
||||
|
||||
#[cfg(feature = "parser")]
|
||||
fn scan_quoted_string_for_incomplete(bytes: &[u8], quote_index: usize) -> QuotedStringScan {
|
||||
let quote = bytes[quote_index];
|
||||
|
||||
@@ -144,6 +144,81 @@ except _IncompleteInputError as exc:
|
||||
else:
|
||||
raise AssertionError("expected _IncompleteInputError")
|
||||
|
||||
|
||||
def _expect_incomplete(source, mode):
|
||||
try:
|
||||
compile(source, "<test>", mode, flags=PY_CF_ALLOW_INCOMPLETE_INPUT)
|
||||
except _IncompleteInputError:
|
||||
return
|
||||
raise AssertionError(f"expected _IncompleteInputError for {source!r} {mode}")
|
||||
|
||||
|
||||
def _expect_syntax(source, mode):
|
||||
try:
|
||||
compile(source, "<test>", mode, flags=PY_CF_ALLOW_INCOMPLETE_INPUT)
|
||||
except _IncompleteInputError:
|
||||
raise AssertionError(f"unexpected _IncompleteInputError for {source!r} {mode}")
|
||||
except SyntaxError:
|
||||
return
|
||||
raise AssertionError(f"expected SyntaxError for {source!r} {mode}")
|
||||
|
||||
|
||||
# eval/single treat empty input as incomplete; exec accepts an empty module.
|
||||
_expect_incomplete("", "eval")
|
||||
_expect_incomplete("", "single")
|
||||
_expect_incomplete("\n", "eval")
|
||||
_expect_incomplete("\n", "single")
|
||||
compile("", "<test>", "exec", flags=PY_CF_ALLOW_INCOMPLETE_INPUT)
|
||||
|
||||
# A plain string still open at EOF is incomplete, except in exec, which
|
||||
# appends a newline and so sees an unescaped newline instead.
|
||||
_expect_incomplete("'abc", "eval")
|
||||
_expect_incomplete("'abc", "single")
|
||||
_expect_syntax("'abc", "exec")
|
||||
# A final `\` eats exec's implicit newline, so the string is still open at EOF.
|
||||
_expect_incomplete("a = 'a\\", "exec")
|
||||
_expect_incomplete("a = 'a\\", "single")
|
||||
_expect_syntax("a = 'a\\", "eval")
|
||||
_expect_incomplete("'''abc", "eval")
|
||||
_expect_incomplete("'''abc", "exec")
|
||||
|
||||
# Single-quoted f/t-strings never set E_EOLS.
|
||||
_expect_syntax("f'", "eval")
|
||||
_expect_syntax("f'", "exec")
|
||||
_expect_syntax("f'", "single")
|
||||
_expect_syntax("f'{1+'", "eval")
|
||||
# An unclosed `{` in the field is EOF (`E_EOF`), so it is incomplete.
|
||||
_expect_incomplete("f'{", "eval")
|
||||
_expect_incomplete("f'{1", "eval")
|
||||
_expect_incomplete("f'''", "eval")
|
||||
_expect_incomplete("f'''", "exec")
|
||||
|
||||
# exec turns a final `\` into a continuation then EOF; single/eval do not.
|
||||
_expect_syntax("9+ \\", "eval")
|
||||
_expect_syntax("9+ \\", "single")
|
||||
_expect_incomplete("9+ \\", "exec")
|
||||
|
||||
# Non-tokenizer whitespace is a hard error, not blank input.
|
||||
_expect_syntax("\xa0", "eval")
|
||||
_expect_syntax("\xa0", "exec")
|
||||
_expect_syntax("\x0b", "eval")
|
||||
|
||||
# A lambda default `=` is not an assignment statement.
|
||||
_expect_incomplete("lambda x='abc", "eval")
|
||||
# A Unicode identifier plus a quote is a plain string, not an f-string.
|
||||
_expect_incomplete("éf'", "eval")
|
||||
# Comment-only single input is invalid syntax, not an indent error.
|
||||
try:
|
||||
compile("# a", "<test>", "single")
|
||||
except IndentationError:
|
||||
raise AssertionError("comment-only single input should not be IndentationError")
|
||||
except SyntaxError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected SyntaxError for comment-only single input")
|
||||
_expect_incomplete("# a", "single")
|
||||
_expect_incomplete(" #x", "single")
|
||||
|
||||
# The source is encoded before it is parsed, so a lone surrogate has to be
|
||||
# reported rather than assumed away.
|
||||
with assert_raises(UnicodeEncodeError):
|
||||
|
||||
Reference in new issue
Block a user