@@ -89,7 +89,7 @@ impl CompileError {
8989 location,
9090 end_location,
9191 source_path : source_file. name ( ) . to_owned ( ) ,
92- is_unclosed_bracket : false ,
92+ is_unclosed_bracket : diagnostic . is_unclosed_bracket ,
9393 is_unclosed_string : diagnostic. is_unclosed_string ,
9494 } )
9595 }
@@ -194,6 +194,7 @@ impl NormalizedParseDiagnostic {
194194 end_location,
195195 ) ;
196196 diagnostic_out. is_unclosed_string = diagnostic. is_unclosed_string ;
197+ diagnostic_out. is_unclosed_bracket = diagnostic. is_unclosed_bracket ;
197198 diagnostic_out
198199 }
199200
@@ -211,6 +212,7 @@ struct CpythonDiagnostic {
211212 message : String ,
212213 range : ruff_text_size:: TextRange ,
213214 is_unclosed_string : bool ,
215+ is_unclosed_bracket : bool ,
214216}
215217
216218impl CpythonDiagnostic {
@@ -226,13 +228,19 @@ impl CpythonDiagnostic {
226228 TextSize :: new ( end as u32 ) ,
227229 ) ,
228230 is_unclosed_string : false ,
231+ is_unclosed_bracket : false ,
229232 }
230233 }
231234
232235 const fn with_unclosed_string ( mut self ) -> Self {
233236 self . is_unclosed_string = true ;
234237 self
235238 }
239+
240+ const fn with_unclosed_bracket ( mut self ) -> Self {
241+ self . is_unclosed_bracket = true ;
242+ self
243+ }
236244}
237245
238246fn cpython_parse_diagnostic_override (
@@ -269,9 +277,11 @@ fn cpython_parse_diagnostic_override(
269277 & error. error,
270278 parser:: ParseErrorType :: Lexical ( parser:: LexicalErrorType :: LineContinuationError )
271279 ) {
272- // Only a backslash at the end of the source is an EOF error.
280+ // exec input gets an implicit trailing newline, so a final `\` is a
281+ // continuation that then hits EOF (`E_EOF`). single/eval see `\` at
282+ // EOF as `E_LINECONT` instead.
273283 let terminal_backslash = source_text. len ( ) . checked_sub ( 1 ) ;
274- if ! matches ! ( mode, Mode :: Eval )
284+ if matches ! ( mode, Mode :: Exec )
275285 && terminal_backslash == Some ( error. location . start ( ) . to_usize ( ) )
276286 {
277287 let loc = source_line_end_location ( source_file, error. location . start ( ) ) ;
@@ -289,7 +299,7 @@ fn cpython_parse_diagnostic_override(
289299 ) ) ;
290300 }
291301
292- source_error ! ( unterminated_string_error( source_text) ) ;
302+ source_error ! ( unterminated_string_error( source_text, mode ) ) ;
293303 source_error ! ( expected_indented_block_error( error, source_text) ) ;
294304
295305 if matches ! (
@@ -494,6 +504,23 @@ fn is_ascii_identifier_char(byte: u8) -> bool {
494504 byte == b'_' || byte. is_ascii_alphanumeric ( )
495505}
496506
507+ fn identifier_continue_before ( bytes : & [ u8 ] , index : usize ) -> bool {
508+ if index == 0 {
509+ return false ;
510+ }
511+ if bytes[ index - 1 ] . is_ascii ( ) {
512+ return is_ascii_identifier_char ( bytes[ index - 1 ] ) ;
513+ }
514+ let mut start = index - 1 ;
515+ while start > 0 && bytes[ start] & 0b1100_0000 == 0b1000_0000 {
516+ start -= 1 ;
517+ }
518+ :: core:: str:: from_utf8 ( & bytes[ start..index] )
519+ . ok ( )
520+ . and_then ( |text| text. chars ( ) . next_back ( ) )
521+ . is_some_and ( |ch| ch == '_' || ch. is_alphanumeric ( ) )
522+ }
523+
497524fn numeric_keyword_suffix ( rest : & [ u8 ] ) -> bool {
498525 rest. starts_with ( b"and" )
499526 || rest. starts_with ( b"else" )
@@ -3894,7 +3921,7 @@ fn non_printable_character_error(source: &str) -> Option<CpythonDiagnostic> {
38943921 None
38953922}
38963923
3897- fn unterminated_string_error ( source : & str ) -> Option < CpythonDiagnostic > {
3924+ fn unterminated_string_error ( source : & str , mode : Mode ) -> Option < CpythonDiagnostic > {
38983925 let bytes = source. as_bytes ( ) ;
38993926 let mut index = 0 ;
39003927 let mut line = 1usize ;
@@ -3921,6 +3948,7 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
39213948 } ;
39223949 index += quote_size;
39233950 let mut has_escaped_quote = false ;
3951+ let mut ended_with_escape = false ;
39243952 let mut closed = false ;
39253953 while index < bytes. len ( ) {
39263954 let c = bytes[ index] ;
@@ -3952,6 +3980,7 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
39523980 if bytes. get ( index + 1 ) == Some ( & quote) {
39533981 has_escaped_quote = true ;
39543982 }
3983+ ended_with_escape = index + 1 >= bytes. len ( ) ;
39553984 index = ( index + 2 ) . min ( bytes. len ( ) ) ;
39563985 } else {
39573986 index += 1 ;
@@ -3964,17 +3993,30 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
39643993 return Some ( error) ;
39653994 }
39663995 let detected_line = if quote_size == 3 { line } else { start_line } ;
3996+ let interpolated = interpolated_string_prefix ( bytes, start) ;
39673997 let diagnostic = CpythonDiagnostic :: new (
39683998 unterminated_string_message (
39693999 detected_line,
39704000 quote_size == 3 ,
39714001 has_escaped_quote,
3972- interpolated_string_prefix ( bytes , start ) ,
4002+ interpolated ,
39734003 ) ,
39744004 start,
39754005 start,
39764006 ) ;
3977- return Some ( if index >= bytes. len ( ) {
4007+ // E_EOLS is only for a plain single-quoted string at EOF.
4008+ // Single-quoted f/t-strings never set it. exec input appends a
4009+ // newline, so a single-quoted literal becomes the newline case
4010+ // unless a final `\` consumes that newline.
4011+ let exec_implicit_newline =
4012+ matches ! ( mode, Mode :: Exec ) && quote_size == 1 && !ended_with_escape;
4013+ let eval_assignment =
4014+ matches ! ( mode, Mode :: Eval ) && eval_has_assignment_before ( bytes, start) ;
4015+ let continuable = index >= bytes. len ( )
4016+ && !( interpolated. is_some ( ) && quote_size == 1 )
4017+ && !exec_implicit_newline
4018+ && !eval_assignment;
4019+ return Some ( if continuable {
39784020 diagnostic. with_unclosed_string ( )
39794021 } else {
39804022 diagnostic
@@ -3987,6 +4029,50 @@ fn unterminated_string_error(source: &str) -> Option<CpythonDiagnostic> {
39874029 None
39884030}
39894031
4032+ fn eval_has_assignment_before ( bytes : & [ u8 ] , end : usize ) -> bool {
4033+ let mut index = 0 ;
4034+ let mut level = 0usize ;
4035+ let mut in_lambda_params = false ;
4036+ while index < end {
4037+ match bytes[ index] {
4038+ b'#' => {
4039+ while index < end && bytes[ index] != b'\n' {
4040+ index += 1 ;
4041+ }
4042+ }
4043+ b'\'' | b'"' => index = skip_quoted_string ( bytes, index) ,
4044+ b'(' | b'[' | b'{' => {
4045+ level += 1 ;
4046+ index += 1 ;
4047+ }
4048+ b')' | b']' | b'}' => {
4049+ level = level. saturating_sub ( 1 ) ;
4050+ index += 1 ;
4051+ }
4052+ b':' if level == 0 => {
4053+ in_lambda_params = false ;
4054+ index += 1 ;
4055+ }
4056+ b'=' if level == 0
4057+ && !in_lambda_params
4058+ && bytes. get ( index + 1 ) != Some ( & b'=' )
4059+ && !matches ! (
4060+ bytes. get( index. saturating_sub( 1 ) ) ,
4061+ Some ( b':' | b'!' | b'<' | b'>' )
4062+ ) =>
4063+ {
4064+ return true ;
4065+ }
4066+ _ if level == 0 && starts_identifier ( bytes, index, b"lambda" ) => {
4067+ in_lambda_params = true ;
4068+ index += b"lambda" . len ( ) ;
4069+ }
4070+ _ => index += 1 ,
4071+ }
4072+ }
4073+ false
4074+ }
4075+
39904076fn invalid_interpolated_string_error ( source : & str ) -> Option < CpythonDiagnostic > {
39914077 let bytes = source. as_bytes ( ) ;
39924078 let mut index = 0 ;
@@ -4161,7 +4247,7 @@ fn interpolated_string_prefix(bytes: &[u8], quote: usize) -> Option<&'static str
41614247 return None ;
41624248 } ;
41634249
4164- if prefix_start > 0 && is_ascii_identifier_char ( bytes[ prefix_start - 1 ] ) {
4250+ if prefix_start > 0 && identifier_continue_before ( bytes, prefix_start ) {
41654251 return None ;
41664252 }
41674253
@@ -4424,11 +4510,10 @@ fn replacement_field_comment_error(
44244510 }
44254511 b'#' => {
44264512 if !bytes[ index..literal_end] . contains ( & b'\n' ) {
4427- return Some ( CpythonDiagnostic :: new (
4428- "'{' was never closed" . to_owned ( ) ,
4429- open,
4430- open + 1 ,
4431- ) ) ;
4513+ return Some (
4514+ CpythonDiagnostic :: new ( "'{' was never closed" . to_owned ( ) , open, open + 1 )
4515+ . with_unclosed_bracket ( ) ,
4516+ ) ;
44324517 }
44334518 index = skip_replacement_field_comment ( bytes, index, end) ;
44344519 continue ;
@@ -5482,6 +5567,7 @@ fn unclosed_replacement_field_error(
54825567 position,
54835568 position + 1 ,
54845569 )
5570+ . with_unclosed_bracket ( )
54855571 } )
54865572}
54875573
@@ -6173,6 +6259,49 @@ fn invalid_unparenthesized_yield_after_comma_error(source: &str) -> Option<Cpyth
61736259 None
61746260}
61756261
6262+ /// Horizontal and vertical space the tokenizer skips, not `str::trim()`.
6263+ const fn is_ascii_tokenizer_whitespace ( c : char ) -> bool {
6264+ matches ! ( c, ' ' | '\t' | '\n' | '\r' | '\x0c' )
6265+ }
6266+
6267+ /// True when every line is empty or a comment after tokenizer whitespace.
6268+ #[ doc( hidden) ]
6269+ #[ must_use]
6270+ pub fn is_blank_python_source ( source : & str ) -> bool {
6271+ source. lines ( ) . all ( |line| {
6272+ let trimmed = line. trim_matches ( is_ascii_tokenizer_whitespace) ;
6273+ trimmed. is_empty ( ) || trimmed. starts_with ( '#' )
6274+ } )
6275+ }
6276+
6277+ fn single_mode_blank_source_error ( source_file : & SourceFile ) -> Option < CompileError > {
6278+ let source = source_file. source_text ( ) ;
6279+ if !is_blank_python_source ( source) {
6280+ return None ;
6281+ }
6282+ let has_indent_only_line = source. lines ( ) . any ( |line| {
6283+ line. trim_matches ( is_ascii_tokenizer_whitespace) . is_empty ( )
6284+ && line. chars ( ) . any ( |c| matches ! ( c, ' ' | '\t' ) )
6285+ } ) ;
6286+ if has_indent_only_line {
6287+ let ( location, end_location) =
6288+ source_locations ( source_file, TextSize :: new ( 0 ) , TextSize :: new ( 0 ) ) ;
6289+ return Some ( CompileError :: Parse ( ParseError {
6290+ error : parser:: ParseErrorType :: UnexpectedIndentation ,
6291+ raw_location : ruff_text_size:: TextRange :: new ( TextSize :: new ( 0 ) , TextSize :: new ( 0 ) ) ,
6292+ location,
6293+ end_location,
6294+ source_path : source_file. name ( ) . to_owned ( ) ,
6295+ is_unclosed_bracket : false ,
6296+ is_unclosed_string : false ,
6297+ } ) ) ;
6298+ }
6299+ Some ( CompileError :: from_source_error (
6300+ source_file,
6301+ CpythonDiagnostic :: new ( "invalid syntax" . to_owned ( ) , 0 , 0 ) ,
6302+ ) )
6303+ }
6304+
61766305/// The byte-order mark is only stripped while decoding source bytes, so one
61776306/// that survives into the text is just a non-printable character. The tokenizer
61786307/// 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>(
65906719 }
65916720 let parsed =
65926721 parsed. map_err ( |err| CompileError :: from_ruff_parse_error ( err, & source_file, mode) ) ?;
6722+ if matches ! ( mode, Mode :: Single )
6723+ && let Some ( error) = single_mode_blank_source_error ( & source_file)
6724+ {
6725+ return Err ( error) ;
6726+ }
65936727 if opts. dont_imply_dedent
65946728 && matches ! ( mode, Mode :: Single )
65956729 && let Some ( error) = dont_imply_dedent_source_error ( & source_file)
0 commit comments