diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 1c5cdf39ed558..9a20cc90bda93 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -2,7 +2,7 @@ use std::fmt::{self, Display}; use ruff_python_ast::token::TokenKind; use ruff_python_ast::{ConstantValue, Expr, PythonVersion}; -use ruff_text_size::{Ranged, TextRange}; +use ruff_text_size::{Ranged, TextRange, TextSize}; use crate::string::InterpolatedStringKind; @@ -90,6 +90,8 @@ impl Display for UnicodeEscapeErrorKind { pub enum InterpolatedStringErrorType { /// Expected a right brace after an opened left brace. UnclosedLbrace, + /// Expected a right brace for a replacement field opened on an earlier line. + UnclosedLbraceOnLine { opening_line: u32 }, /// An invalid conversion flag was encountered. InvalidConversionFlag, /// A single right brace was encountered. @@ -126,6 +128,9 @@ impl std::fmt::Display for InterpolatedStringErrorType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { Self::UnclosedLbrace => f.write_str("expecting '}'"), + Self::UnclosedLbraceOnLine { opening_line } => { + write!(f, "expecting '}}' to close '{{' on line {opening_line}") + } Self::InvalidConversionFlag => write!(f, "invalid conversion character"), Self::SingleRbrace => f.write_str("single '}' is not allowed"), Self::UnterminatedString { .. } => write!(f, "unterminated string"), @@ -195,9 +200,26 @@ pub enum ParseErrorType { /// An unexpected error occurred. OtherError(String), + /// A missing-comma hint whose concatenated literal is displayed on its final line. + /// The original expression range controls error precedence; `diagnostic_start` can be + /// after the displayed range's end, which a `TextRange` cannot represent. + MissingCommaAfterConcatenatedLiteral { + expression_range: TextRange, + diagnostic_start: TextSize, + }, + /// An error specific to stringified annotations occurred. StringAnnotationError(&'static str), + /// A `raise from` statement has no exception expression. + MissingRaiseException, + /// A `raise` statement ends immediately after `from`. + MissingRaiseCause, + /// An unparenthesized `with` item list ends with a comma. + TrailingCommaInWith, + /// `lazy` follows the module name instead of preceding `from`. + MisplacedLazyImport, + /// An empty slice was found during parsing, e.g `data[]`. EmptySlice, /// An empty global names list was found during parsing. @@ -224,10 +246,24 @@ pub enum ParseErrorType { InvalidYieldExpressionUsage, /// An invalid usage of a starred expression was found. InvalidStarredExpressionUsage, + /// An unpacked conditional expression is missing parentheses. + InvalidConditionalUnpacking { double_starred: bool }, + /// Only the else branch of a conditional expression is unpacked. + InvalidConditionalBranchUnpacking { double_starred: bool }, + /// Dictionary unpacking is used in a list or generator comprehension. + InvalidComprehensionDictUnpacking { generator: bool }, + /// An unpacked expression is used as a dictionary key. + InvalidDictKeyUnpacking { double_starred: bool }, + /// An unpacked expression is used as a dictionary value. + InvalidDictValueUnpacking { double_starred: bool }, + /// Dictionary unpacking is used outside a dictionary or call argument. + InvalidDictUnpacking, /// A star pattern was found outside a sequence pattern. InvalidStarPatternUsage, /// An underscore was used as a binding target in a match pattern. InvalidMatchPatternTarget, + /// A complete case clause was found outside a match statement. + CaseOutsideMatch, /// A parameter was found after a vararg. ParamAfterVarKeywordParam, @@ -247,6 +283,10 @@ pub enum ParseErrorType { kind: ExpressionKind, maybe_comparison: bool, }, + /// A named expression is used in an assertion without its required parentheses. + NamedExpressionWithoutParentheses, + /// A mapping pattern contains an entry after its double-star rest pattern. + MappingRestPatternNotLast, /// A name is assigned to where `==` or `:=` may have been meant. AssignmentInsteadOfComparison, /// A `yield` expression is assigned to. @@ -450,6 +490,8 @@ pub enum BlockClause { Finally, Match, Case, + /// A standalone case uses the generic invalid-block diagnostic. + StandaloneCase, Class, FunctionDef, } @@ -468,7 +510,7 @@ impl std::fmt::Display for BlockClause { Self::ExceptStar => "'except*' statement", Self::Finally => "'finally' statement", Self::Match => "'match' statement", - Self::Case => "'case' statement", + Self::Case | Self::StandaloneCase => "'case' statement", Self::Class => "class definition", Self::FunctionDef => "function definition", }) @@ -493,6 +535,13 @@ impl std::fmt::Display for ParseErrorType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { ParseErrorType::OtherError(msg) => f.write_str(msg), + ParseErrorType::MissingCommaAfterConcatenatedLiteral { .. } => { + f.write_str("invalid syntax. Perhaps you forgot a comma?") + } + ParseErrorType::ExpectedIndentedBlock { + clause: BlockClause::StandaloneCase, + .. + } => f.write_str("expected an indented block"), ParseErrorType::ExpectedIndentedBlock { clause, line } => { write!( f, @@ -538,6 +587,49 @@ impl std::fmt::Display for ParseErrorType { ParseErrorType::InvalidLambdaExpressionUsage => { f.write_str("lambda expression cannot be used here") } + ParseErrorType::InvalidConditionalUnpacking { double_starred } => { + let kind = if *double_starred { + "double starred" + } else { + "starred" + }; + write!( + f, + "invalid {kind} expression. Did you forget to wrap the conditional expression in parentheses?" + ) + } + ParseErrorType::InvalidConditionalBranchUnpacking { double_starred } => { + if *double_starred { + f.write_str( + "cannot use dict unpacking on only part of a conditional expression", + ) + } else { + f.write_str("cannot unpack only part of a conditional expression") + } + } + ParseErrorType::InvalidComprehensionDictUnpacking { generator } => { + let kind = if *generator { + "generator expression" + } else { + "list comprehension" + }; + write!(f, "cannot use dict unpacking in {kind}") + } + ParseErrorType::InvalidDictKeyUnpacking { double_starred } + | ParseErrorType::InvalidDictValueUnpacking { double_starred } => { + let kind = if *double_starred { + "dict unpacking" + } else { + "a starred expression" + }; + let position = if matches!(self, ParseErrorType::InvalidDictKeyUnpacking { .. }) { + "key" + } else { + "value" + }; + write!(f, "cannot use {kind} in a dictionary {position}") + } + ParseErrorType::InvalidDictUnpacking => f.write_str("cannot use dict unpacking here"), ParseErrorType::InvalidStarredExpressionUsage => f.write_str("invalid syntax"), ParseErrorType::PositionalAfterKeywordArgument => { f.write_str("positional argument follows keyword argument") @@ -545,6 +637,18 @@ impl std::fmt::Display for ParseErrorType { ParseErrorType::PositionalAfterKeywordUnpacking => { f.write_str("positional argument follows keyword argument unpacking") } + ParseErrorType::MissingRaiseException => { + f.write_str("did you forget an expression between 'raise' and 'from'?") + } + ParseErrorType::MissingRaiseCause => { + f.write_str("did you forget an expression after 'from'?") + } + ParseErrorType::TrailingCommaInWith => { + f.write_str("the last 'with' item has a trailing comma") + } + ParseErrorType::MisplacedLazyImport => { + f.write_str("use 'lazy from ... ' instead of 'from ... lazy import'") + } ParseErrorType::EmptySlice => f.write_str("expected index or slice expression"), ParseErrorType::EmptyGlobalNames | ParseErrorType::EmptyNonlocalNames @@ -554,27 +658,30 @@ impl std::fmt::Display for ParseErrorType { } ParseErrorType::EmptyTypeParams => f.write_str("Type parameter list cannot be empty"), ParseErrorType::ParamAfterVarKeywordParam => { - f.write_str("arguments cannot follow var-keyword argument") + f.write_str("parameters cannot follow var-keyword parameter") } ParseErrorType::NonDefaultParamAfterDefaultParam => { f.write_str("parameter without a default follows parameter with a default") } ParseErrorType::ExpectedKeywordParam => { - f.write_str("named arguments must follow bare *") + f.write_str("named parameters must follow bare *") } ParseErrorType::VarKeywordParameterWithDefault => { - f.write_str("var-keyword argument cannot have default value") + f.write_str("var-keyword parameter cannot have default value") } ParseErrorType::ExpectedColonAfterDictionaryKey => { f.write_str("':' expected after dictionary key") } ParseErrorType::VarParameterWithDefault => { - f.write_str("var-positional argument cannot have default value") + f.write_str("var-positional parameter cannot have default value") } ParseErrorType::InvalidStarPatternUsage => { f.write_str("cannot use starred expression here") } ParseErrorType::InvalidMatchPatternTarget => f.write_str("cannot use '_' as a target"), + ParseErrorType::CaseOutsideMatch => { + f.write_str("case statement must be inside match statement") + } ParseErrorType::ExpectedRealNumber => { f.write_str("expected a real number in complex literal pattern") } @@ -596,6 +703,12 @@ impl std::fmt::Display for ParseErrorType { f, "cannot assign to {kind} here. Maybe you meant '==' instead of '='?" ), + ParseErrorType::NamedExpressionWithoutParentheses => { + f.write_str("cannot use named expression without parentheses here") + } + ParseErrorType::MappingRestPatternNotLast => f.write_str( + "double star pattern must be the last (right-most) subpattern in the mapping pattern", + ), ParseErrorType::AssignmentInsteadOfComparison => { f.write_str("invalid syntax. Maybe you meant '==' or ':=' instead of '='?") } @@ -820,8 +933,14 @@ impl LexicalErrorType { matches!( self, Self::UnclosedStringError { .. } - | Self::FStringError(InterpolatedStringErrorType::UnclosedLbrace) - | Self::TStringError(InterpolatedStringErrorType::UnclosedLbrace) + | Self::FStringError( + InterpolatedStringErrorType::UnclosedLbrace + | InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } + ) + | Self::TStringError( + InterpolatedStringErrorType::UnclosedLbrace + | InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } + ) ) } @@ -843,6 +962,7 @@ impl LexicalErrorType { | InterpolatedStringErrorType::SingleRbrace | InterpolatedStringErrorType::NewlineInFormatSpec | InterpolatedStringErrorType::UnclosedLbrace + | InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } | InterpolatedStringErrorType::UnmatchedBracket(_) ), Self::UnclosedStringError { .. } diff --git a/crates/ruff_python_parser/src/lexer.rs b/crates/ruff_python_parser/src/lexer.rs index c668a98469170..5a0e3c29639ca 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -1078,7 +1078,7 @@ impl<'src> Lexer<'src> { == self.current_flags.is_triple_quoted() => { LexicalErrorType::from_interpolated_string_error( - InterpolatedStringErrorType::UnclosedLbrace, + self.unclosed_replacement_field_error(), interpolated_string.kind(), ) } @@ -1114,6 +1114,30 @@ impl<'src> Lexer<'src> { u32::try_from(newlines + 1).unwrap() } + /// Returns the opening brace of the active replacement field and whether it is in a + /// format specifier. Brackets inside its expression are not replacement fields. + pub(crate) fn replacement_field(&self) -> Option<(TextSize, bool)> { + let context = self.interpolated_strings.current()?; + if self.nesting <= context.nesting() { + return None; + } + let implicit = self.nesting as usize - self.brackets.len(); + let index = + (context.replacement_field_nesting(self.nesting) as usize).checked_sub(implicit)?; + let bracket = self.brackets.get(index)?; + (bracket.kind == '{').then_some((bracket.start, context.is_in_format_spec(self.nesting))) + } + + fn unclosed_replacement_field_error(&self) -> InterpolatedStringErrorType { + if let Some((start, _)) = self.replacement_field() { + let opening_line = self.line_number(start); + if opening_line != self.line_number(self.token_range().start()) { + return InterpolatedStringErrorType::UnclosedLbraceOnLine { opening_line }; + } + } + InterpolatedStringErrorType::UnclosedLbrace + } + /// Returns the error for reaching the end of the source unexpectedly at `range`: the innermost /// bracket that is not closed, if any. fn eof_error(&self, range: TextRange) -> LexicalError { diff --git a/crates/ruff_python_parser/src/lexer/interpolated_string.rs b/crates/ruff_python_parser/src/lexer/interpolated_string.rs index 1312f91765921..8b710bdf7fc0c 100644 --- a/crates/ruff_python_parser/src/lexer/interpolated_string.rs +++ b/crates/ruff_python_parser/src/lexer/interpolated_string.rs @@ -92,6 +92,11 @@ impl InterpolatedStringContext { current_nesting.saturating_sub(self.nesting) } + /// Returns the nesting level immediately outside the current replacement field. + pub(crate) const fn replacement_field_nesting(&self, current_nesting: u32) -> u32 { + self.nesting + self.format_spec_depth - self.is_in_format_spec(current_nesting) as u32 + } + /// Returns `true` if the lexer is in an f-string expression or t-string interpolation i.e., between /// two curly braces. pub(crate) const fn is_in_interpolation(&self, current_nesting: u32) -> bool { diff --git a/crates/ruff_python_parser/src/parser/case_diagnostics.rs b/crates/ruff_python_parser/src/parser/case_diagnostics.rs new file mode 100644 index 0000000000000..90fee7d974eb3 --- /dev/null +++ b/crates/ruff_python_parser/src/parser/case_diagnostics.rs @@ -0,0 +1,155 @@ +//! The invalid standalone-case production. This is diagnostic-only speculation: +//! the caller always parses the original statement again for syntax and tokens. + +use ruff_python_ast::token::TokenKind; +use ruff_python_ast::{Expr, Pattern}; +use ruff_text_size::TextRange; + +use super::Parser; +use super::expression::ExpressionContext; +use crate::{BlockClause, ParseError, ParseErrorType}; + +impl Parser<'_> { + pub(super) fn standalone_case_diagnostic(&mut self) -> Option { + if !self.at(TokenKind::Case) + || !self.errors.is_empty() + || self + .token_before(self.node_start()) + .is_some_and(|(kind, _)| { + !matches!( + kind, + TokenKind::Newline | TokenKind::Indent | TokenKind::Dedent + ) + }) + { + return None; + } + + let checkpoint = self.checkpoint(); + // These recovery fields are not covered by the normal parser checkpoint. + let unclosed_bracket = self.first_unclosed_bracket_recovery; + let escape_error = self.interpolated_string_escape_error.clone(); + let replacement_field = self.replacement_field; + let diagnostic = self.parse_invalid_standalone_case(); + self.rewind(checkpoint); + self.first_unclosed_bracket_recovery = unclosed_bracket; + self.interpolated_string_escape_error = escape_error; + self.replacement_field = replacement_field; + diagnostic + } + + fn parse_invalid_standalone_case(&mut self) -> Option { + let start = self.node_start(); + self.bump(TokenKind::Case); + let pattern = self.parse_match_patterns(); + if !self.errors.is_empty() { + return self.standalone_case_inner_error(); + } + if !pattern_has_valid_value_names(&pattern) { + return None; + } + + if self.eat(TokenKind::If) { + if !self.at_expr() { + return None; + } + self.parse_named_expression_or_higher(ExpressionContext::default()); + if !self.errors.is_empty() { + return self.standalone_case_inner_error(); + } + } + if !self.at(TokenKind::Colon) { + return None; + } + let header_range = TextRange::new(start, self.current_token_range().end()); + self.bump(TokenKind::Colon); + self.parse_body(BlockClause::StandaloneCase, start); + + if self + .tokens + .has_tokenizer_error_in(TextRange::new(start, self.current_token_range().end())) + { + return None; + } + if self.errors.is_empty() { + Some(ParseError { + error: ParseErrorType::CaseOutsideMatch, + location: header_range, + }) + } else { + self.standalone_case_inner_error() + } + } + + /// A malformed production does not establish a standalone case. An invalid + /// rule reached while parsing its pattern, guard, or body can still raise + /// its own diagnostic before the enclosing standalone-case rule fires. + fn standalone_case_inner_error(&self) -> Option { + self.errors + .first() + .filter(|error| { + matches!( + error.error, + ParseErrorType::CaseOutsideMatch + | ParseErrorType::ExpectedIndentedBlock { .. } + | ParseErrorType::InvalidMatchPatternTarget + | ParseErrorType::InvalidPatternTarget(_) + | ParseErrorType::MissingRaiseException + | ParseErrorType::MissingRaiseCause + | ParseErrorType::NamedExpressionWithoutParentheses + | ParseErrorType::MappingRestPatternNotLast + | ParseErrorType::TrailingCommaInWith + | ParseErrorType::MisplacedLazyImport + | ParseErrorType::InvalidAssignmentTarget { .. } + | ParseErrorType::InvalidAnnotatedAssignmentTarget + | ParseErrorType::InvalidAugmentedAssignmentTarget(_) + | ParseErrorType::InvalidDeleteTarget(_) + ) + }) + .cloned() + } +} + +/// The recovery parser permits an underscore at the root of a value or class +/// pattern. In the grammar it commits to the wildcard instead, so `_.x` cannot +/// complete the pattern even when the recovered AST is an attribute. +fn pattern_has_valid_value_names(pattern: &Pattern) -> bool { + match pattern { + Pattern::MatchValue(pattern) => value_has_valid_root(&pattern.value), + Pattern::MatchSingleton(_) | Pattern::MatchStar(_) => true, + Pattern::MatchSequence(pattern) => { + pattern.patterns.iter().all(pattern_has_valid_value_names) + } + Pattern::MatchMapping(pattern) => { + pattern.patterns.iter().all(pattern_has_valid_value_names) + } + Pattern::MatchClass(pattern) => { + value_has_valid_root(&pattern.cls) + && pattern + .arguments + .patterns + .iter() + .all(pattern_has_valid_value_names) + && pattern + .arguments + .keywords + .iter() + .all(|keyword| pattern_has_valid_value_names(&keyword.pattern)) + } + Pattern::MatchAs(pattern) => pattern + .pattern + .as_deref() + .is_none_or(pattern_has_valid_value_names), + Pattern::MatchOr(pattern) => pattern.patterns.iter().all(pattern_has_valid_value_names), + } +} + +fn value_has_valid_root(mut value: &Expr) -> bool { + while let Expr::Attribute(attribute) = value { + value = &attribute.value; + } + match value { + Expr::Name(name) => name.id != "_", + _ => true, + } +} diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index fcf279667391a..25f74cd8ea4f0 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -451,12 +451,63 @@ impl<'src> Parser<'src> { return; }; let range = TextRange::new(first.start(), second_end); + if let Some((diagnostic_start, end)) = self.concatenated_literal_comma_range(first) { + self.add_error( + ParseErrorType::MissingCommaAfterConcatenatedLiteral { + expression_range: range, + diagnostic_start, + }, + TextRange::new(diagnostic_start.min(end), end), + ); + return; + } self.add_error( ParseErrorType::OtherError("invalid syntax. Perhaps you forgot a comma?".to_string()), range, ); } + /// Points a multiline concatenation's missing-comma hint at its final literal line, + /// retaining the first literal's column, as CPython does. + fn concatenated_literal_comma_range(&self, first: &ParsedExpr) -> Option<(TextSize, TextSize)> { + let last = match &first.expr { + Expr::StringLiteral(string) if string.value.is_implicit_concatenated() => { + string.value.iter().next_back()?.range() + } + Expr::BytesLiteral(bytes) if bytes.value.is_implicit_concatenated() => { + bytes.value.iter().next_back()?.range() + } + Expr::FString(string) if string.value.is_implicit_concatenated() => { + string.value.iter().next_back()?.range() + } + Expr::TString(string) if string.value.is_implicit_concatenated() => { + string.value.iter().next_back()?.range() + } + _ => return None, + }; + let first_start = first.start().to_usize(); + let end = last.end().to_usize(); + let line_start = |offset| { + self.source[..offset] + .rfind(['\n', '\r']) + .map_or(0, |i| i + 1) + }; + let first_line = line_start(first_start); + let last_line = line_start(end); + if first_line == last_line { + return None; + } + let line_end = self.source[end..] + .find(['\n', '\r']) + .map_or(self.source.len(), |i| end + i); + let mut start = (last_line + first_start - first_line).min(line_end); + // Keep the projected byte column at a valid character boundary. + while !self.source.is_char_boundary(start) { + start -= 1; + } + Some((TextSize::try_from(start).unwrap(), last.end())) + } + /// Reports the Python 2 `print` or `exec` statement named `keyword` that starts at `start` /// and whose operands start at the current token, unless no prefix of the operands is a /// complete expression. @@ -686,22 +737,32 @@ impl<'src> Parser<'src> { self.parse_primary_expression(context) } - /// Parses an expression with a minimum precedence of bitwise `or`. - /// - /// This methods actually parses the expression using the `expression` rule - /// of the [Python grammar] and then validates the parsed expression. In a - /// sense, it matches the `bitwise_or` rule of the [Python grammar]. - /// - /// [Python grammar]: https://docs.python.org/3/reference/grammar.html - fn parse_expression_with_bitwise_or_precedence(&mut self) -> ParsedExpr { - let parsed_expr = - self.parse_conditional_expression_without_comma_check(ExpressionContext::default()); - + /// Checks the bitwise-or operand and adds the conditional-unpacking diagnostic. + fn validate_unpacking_precedence( + &mut self, + parsed_expr: &ParsedExpr, + start: TextSize, + double_starred: bool, + ) { if parsed_expr.is_parenthesized { // Parentheses resets the precedence, so we don't need to validate it. - return parsed_expr; + return; + } + if parsed_expr.expr.is_if_expr() { + self.add_error( + ParseErrorType::InvalidConditionalUnpacking { double_starred }, + TextRange::new(start, parsed_expr.end()), + ); + return; } + self.validate_bitwise_or_precedence(parsed_expr); + } + + fn validate_bitwise_or_precedence(&mut self, parsed_expr: &ParsedExpr) { + if parsed_expr.is_parenthesized { + return; + } let expr_name = match parsed_expr.expr { Expr::Compare(_) => "comparison", Expr::BoolOp(_) @@ -711,15 +772,13 @@ impl<'src> Parser<'src> { }) => "boolean", Expr::If(_) => "conditional", Expr::Lambda(_) => "lambda", - _ => return parsed_expr, + _ => return, }; self.add_error( ParseErrorType::OtherError(format!("{expr_name} expression cannot be used here")), - &parsed_expr, + parsed_expr, ); - - parsed_expr } /// Parses a name. @@ -994,6 +1053,7 @@ impl<'src> Parser<'src> { let keywords_snapshot = self.keyword_scratch.snapshot(); let mut seen_keyword_argument = false; // foo = 1 let mut seen_keyword_unpacking = false; // **foo + let mut first_argument = true; // The first positional argument after a keyword argument, reported where the arguments // end, and the number of errors before it. let mut positional_after_keyword: Option<(ParseErrorType, usize)> = None; @@ -1001,8 +1061,43 @@ impl<'src> Parser<'src> { let has_trailing_comma = self.parse_comma_separated_list(RecoveryContextKind::Arguments, |parser| { let argument_start = parser.node_start(); + let is_first_argument = first_argument; + first_argument = false; if parser.eat(TokenKind::DoubleStar) { let mut value = parser.parse_conditional_expression_or_higher(); + if is_first_argument + && matches!(context, ArgumentsContext::Call) + && matches!( + parser.current_token_kind(), + TokenKind::Async | TokenKind::For + ) + { + let for_range = parser.current_token_range(); + let for_kind = parser.current_token_kind(); + let errors_start = parser.errors.len(); + let generators = parser.parse_generators(); + let (error, location) = if parser + .has_valid_first_comprehension_clause(&generators, errors_start) + { + ( + ParseErrorType::InvalidComprehensionDictUnpacking { + generator: true, + }, + TextRange::new(argument_start, value.end()), + ) + } else { + ( + ParseErrorType::ExpectedToken { + expected: TokenKind::Comma, + found: for_kind, + }, + for_range, + ) + }; + parser + .errors + .insert(errors_start, crate::ParseError { error, location }); + } if parser.eat(TokenKind::Equal) { // test_err args_keyword_unpacking_assignment @@ -2615,6 +2710,103 @@ impl<'src> Parser<'src> { } } + /// An invalid comprehension only needs one complete target/iterable clause. + /// Later incomplete filters or clauses do not undo that first successful clause. + fn has_valid_first_comprehension_clause( + &self, + generators: &[ast::Comprehension], + errors_start: usize, + ) -> bool { + generators.first().is_some_and(|clause| { + self.errors[errors_start..] + .iter() + .all(|error| error.location.start() > clause.iter.end()) + }) + } + + /// Recovers a dictionary unpack in a sequence, using the same parse for diagnostics. + fn parse_invalid_sequence_dict_unpacking(&mut self, start: TextSize, generator: bool) -> Expr { + let stars = self.current_token_range(); + let errors_start = self.errors.len(); + self.bump(TokenKind::DoubleStar); + let has_operand = self.at_expr(); + let has_bitwise_operand = has_operand + && !matches!( + self.current_token_kind(), + TokenKind::Lambda + | TokenKind::Not + | TokenKind::Yield + | TokenKind::Star + | TokenKind::DoubleStar + ); + let value = self.parse_conditional_expression_or_higher(); + let value_end = value.end(); + let operand_is_valid = self.errors.len() == errors_start; + let is_group = generator && self.at(TokenKind::Rpar); + let clause_errors_start = self.errors.len(); + let (expression, valid_comprehension) = + if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { + if generator { + let expression = + self.parse_generator_expression(value.expr, start, Parenthesized::Yes); + let valid = self.has_valid_first_comprehension_clause( + &expression.generators, + clause_errors_start, + ); + (Expr::Generator(expression), valid) + } else { + let expression = self.parse_list_comprehension_expression(value.expr, start); + let valid = self.has_valid_first_comprehension_clause( + &expression.generators, + clause_errors_start, + ); + (Expr::ListComp(expression), valid) + } + } else if !generator { + ( + Expr::List(self.parse_list_expression(value.expr, start)), + false, + ) + } else if self.at(TokenKind::Comma) { + let tuple = + self.parse_tuple_expression(value.expr, start, Parenthesized::Yes, |p| { + p.parse_named_expression_or_higher(ExpressionContext::starred_bitwise_or()) + }); + (Expr::Tuple(tuple), false) + } else { + self.expect(TokenKind::Rpar); + (value.expr, false) + }; + let invalid_target = self.errors[clause_errors_start..] + .iter() + .any(|error| matches!(error.error, ParseErrorType::InvalidAssignmentTarget { .. })); + let diagnostic = if invalid_target || (!operand_is_valid && has_operand) { + None + } else if operand_is_valid && valid_comprehension { + Some(( + ParseErrorType::InvalidComprehensionDictUnpacking { generator }, + TextRange::new(stars.start(), value_end), + )) + } else if operand_is_valid && has_bitwise_operand { + Some((ParseErrorType::InvalidDictUnpacking, stars)) + } else if operand_is_valid && is_group { + Some(( + ParseErrorType::OtherError("cannot use double starred expression here".to_string()), + stars, + )) + } else if !operand_is_valid || !has_bitwise_operand { + Some((ParseErrorType::ExpectedExpression, stars)) + } else { + None + }; + if let Some((error, location)) = diagnostic { + // The invalid production starts before any clause-recovery errors. + self.errors + .insert(errors_start, crate::ParseError { error, location }); + } + expression + } + /// Parses a list or a list comprehension expression. /// /// # Panics @@ -2647,6 +2839,9 @@ impl<'src> Parser<'src> { } // Parse the first element with a more general rule and limit it later. + if self.at(TokenKind::DoubleStar) { + return self.parse_invalid_sequence_dict_unpacking(start, false); + } let first_element = self.parse_named_expression_or_higher( ExpressionContext::starred_bitwise_or().with_for_excluded(), ); @@ -2743,8 +2938,10 @@ impl<'src> Parser<'src> { if self.eat(TokenKind::DoubleStar) { // Handle dictionary unpacking. Here, the grammar is `'**' bitwise_or` // which requires limiting the expression. - let value = self.parse_expression_with_bitwise_or_precedence(); + let value = + self.parse_conditional_expression_without_comma_check(ExpressionContext::default()); let unpack_range = TextRange::new(after_brace, value.range().end()); + self.validate_unpacking_precedence(&value, after_brace, true); if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { self.add_unsupported_syntax_error( @@ -2754,13 +2951,21 @@ impl<'src> Parser<'src> { unpack_range, ); - return Expr::DictComp( - self.parse_dictionary_comprehension_expression(None, value.expr, start), - ); + return Expr::DictComp(self.parse_dictionary_comprehension_expression( + None, + value.expr, + start, + Some(TextRange::at(after_brace, TextSize::new(2))), + )); } if self.at(TokenKind::Colon) { - self.add_error(ParseErrorType::InvalidStarredExpressionUsage, unpack_range); + self.add_error( + ParseErrorType::InvalidDictKeyUnpacking { + double_starred: true, + }, + unpack_range, + ); self.bump(TokenKind::Colon); let dict_value = self.parse_conditional_expression_or_higher(); @@ -2770,6 +2975,7 @@ impl<'src> Parser<'src> { Some(value.expr), dict_value.expr, start, + None, )); } @@ -2829,7 +3035,9 @@ impl<'src> Parser<'src> { if !key_or_element.is_parenthesized { match key_or_element.expr { Expr::Starred(_) => self.add_error( - ParseErrorType::InvalidStarredExpressionUsage, + ParseErrorType::InvalidDictKeyUnpacking { + double_starred: false, + }, &key_or_element.expr, ), Expr::Named(_) => self.add_error( @@ -2842,24 +3050,14 @@ impl<'src> Parser<'src> { let colon_range = self.current_token_range(); self.bump(TokenKind::Colon); - let value = if self.at(TokenKind::DoubleStar) { - let unpack_start = self.node_start(); - self.bump(TokenKind::DoubleStar); - let value = self.parse_expression_with_bitwise_or_precedence(); - self.add_error( - ParseErrorType::InvalidStarredExpressionUsage, - TextRange::new(unpack_start, value.range().end()), - ); - value - } else { - self.parse_dictionary_value(colon_range) - }; + let value = self.parse_dictionary_value(colon_range); if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { Expr::DictComp(self.parse_dictionary_comprehension_expression( Some(key_or_element.expr), value.expr, start, + None, )) } else { Expr::Dict(self.parse_dictionary_expression( @@ -2904,30 +3102,16 @@ impl<'src> Parser<'src> { .into(); } - if self.at(TokenKind::DoubleStar) { - let double_star_range = self.current_token_range(); - let checkpoint = self.checkpoint(); - self.bump(TokenKind::DoubleStar); - if self.at_expr() { - let mut parsed_expr = self.parse_conditional_expression_or_higher(); - if self.eat(TokenKind::Rpar) { - // test_err parenthesized_double_starred_expr - // (**x) - self.add_error( - ParseErrorType::OtherError( - "cannot use double starred expression here".to_string(), - ), - double_star_range, - ); - parsed_expr.is_parenthesized = true; - return parsed_expr; - } - } - self.rewind(checkpoint); - } - // Use the more general rule of the three to parse the first element // and limit it later. + if self.at(TokenKind::DoubleStar) { + // test_err parenthesized_double_starred_expr + // (**x) + return ParsedExpr { + expr: self.parse_invalid_sequence_dict_unpacking(start, true), + is_parenthesized: true, + }; + } let mut parsed_expr = self.parse_named_expression_or_higher( ExpressionContext::yield_or_starred_bitwise_or().with_for_excluded(), ); @@ -3153,17 +3337,27 @@ impl<'src> Parser<'src> { self.parse_comma_separated_list(RecoveryContextKind::DictElements, |parser| { if parser.eat(TokenKind::DoubleStar) { - // Handle dictionary unpacking. Here, the grammar is `'**' bitwise_or` - // which requires limiting the expression. + // Subsequent dictionary spreads retain the ordinary display grammar. + let value = parser + .parse_conditional_expression_without_comma_check(ExpressionContext::default()); + parser.validate_bitwise_or_precedence(&value); items.push(ast::DictItem { key: None, - value: parser.parse_expression_with_bitwise_or_precedence().expr, + value: value.expr, }); } else { // A missing comma is not reported in a key after the first item. - let key = parser - .parse_conditional_expression_without_comma_check(ExpressionContext::default()) - .expr; + let key = parser.parse_conditional_expression_without_comma_check( + ExpressionContext::starred_bitwise_or(), + ); + if key.is_unparenthesized_starred_expr() { + parser.add_error( + ParseErrorType::InvalidDictKeyUnpacking { + double_starred: false, + }, + &key, + ); + } if !parser.at(TokenKind::Colon) { // test_err dict_missing_colon_after_key // {a: 1, b} @@ -3181,7 +3375,7 @@ impl<'src> Parser<'src> { parser.expect(TokenKind::Colon); items.push(ast::DictItem { - key: Some(key), + key: Some(key.expr), value: parser.parse_dictionary_value(colon_range).expr, }); } @@ -3211,18 +3405,23 @@ impl<'src> Parser<'src> { ), colon_range, ); - } else if self.at(TokenKind::Star) { + } else if matches!( + self.current_token_kind(), + TokenKind::Star | TokenKind::DoubleStar + ) { // test_err dict_starred_value // {a: *b} // {a: 1, b: *c} - let star_start = self.node_start(); - self.bump(TokenKind::Star); - let value = self.parse_expression_with_bitwise_or_precedence(); + let start = self.node_start(); + let double_starred = self.at(TokenKind::DoubleStar); + self.bump_any(); + let value = self.parse_binary_expression_or_higher( + OperatorPrecedence::ComparisonsMembershipIdentity, + ExpressionContext::default(), + ); self.add_error( - ParseErrorType::OtherError( - "cannot use a starred expression in a dictionary value".to_string(), - ), - TextRange::new(star_start, value.end()), + ParseErrorType::InvalidDictValueUnpacking { double_starred }, + TextRange::new(start, value.end()), ); return value; } @@ -3387,9 +3586,15 @@ impl<'src> Parser<'src> { key: Option, value: Expr, start: TextSize, + unpacking: Option, ) -> ast::ExprDictComp { let generators = self.parse_generators(); + if self.at(TokenKind::Colon) + && let Some(stars) = unpacking + { + self.add_error(ParseErrorType::InvalidDictUnpacking, stars); + } self.expect(TokenKind::Rbrace); ast::ExprDictComp { @@ -3467,6 +3672,7 @@ impl<'src> Parser<'src> { ); } + let errors_start = self.errors.len(); let parsed_expr = match context.starred_expression_precedence() { StarredExpressionPrecedence::Conditional => self .parse_conditional_expression_or_higher_impl( @@ -3477,9 +3683,27 @@ impl<'src> Parser<'src> { context.disallow_starred_expressions(), ), StarredExpressionPrecedence::BitwiseOr => { - self.parse_expression_with_bitwise_or_precedence() + let value = self + .parse_conditional_expression_without_comma_check(ExpressionContext::default()); + self.validate_unpacking_precedence(&value, start, false); + value } }; + // CPython retries invalid_starred_expression_unpacking only after a + // failed parse. Its conditional rule also applies to call arguments. + if errors_start == 0 + && self.errors.is_empty() + && self.deferred_starred_unpacking_error.is_none() + && !parsed_expr.is_parenthesized + && parsed_expr.expr.is_if_expr() + { + self.deferred_starred_unpacking_error = Some(crate::ParseError { + error: ParseErrorType::InvalidConditionalUnpacking { + double_starred: false, + }, + location: TextRange::new(start, parsed_expr.end()), + }); + } ast::ExprStarred { value: Box::new(parsed_expr.expr), @@ -3722,13 +3946,25 @@ impl<'src> Parser<'src> { let test = self.parse_simple_expression(ExpressionContext::default()); - if self.expect(TokenKind::Else) - && (!self.at_expr() - || matches!( - self.current_token_kind(), - TokenKind::Star | TokenKind::DoubleStar | TokenKind::Yield - )) + let has_else = self.expect(TokenKind::Else); + if has_else + && matches!( + self.current_token_kind(), + TokenKind::Star | TokenKind::DoubleStar + ) { + self.add_error( + ParseErrorType::InvalidConditionalBranchUnpacking { + double_starred: self.at(TokenKind::DoubleStar), + }, + self.current_token_range(), + ); + // Recover the operand rather than treating `**` as a binary power + // with an empty left-hand side outside its parent range. + if self.at(TokenKind::DoubleStar) { + self.bump(TokenKind::DoubleStar); + } + } else if has_else && (!self.at_expr() || self.at(TokenKind::Yield)) { // test_err if_expr_statement_after_else // x = 1 if 1 else pass // x = 1 if 1 else @@ -3967,6 +4203,7 @@ bitflags! { /// This flag is set when the `for` keyword, or `async` starting `async for`, should be /// excluded from an expression. const EXCLUDE_FOR = 1 << 4; + } } diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 49dd4a5c66a96..c6d4fc9faf186 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -31,6 +31,7 @@ use crate::{Parsed, Tokens}; pub use crate::parser::options::ParseOptions; +mod case_diagnostics; mod expression; mod helpers; mod options; @@ -76,6 +77,10 @@ pub(crate) struct Parser<'src> { /// Stores all the syntax errors found during the parsing. errors: Vec, + /// The first conditional unpacking error from the invalid-grammar fallback, + /// used only when the overall parse fails. + deferred_starred_unpacking_error: Option, + /// Stores non-fatal syntax errors found during parsing, such as version-related errors. unsupported_syntax_errors: Vec, @@ -158,6 +163,7 @@ impl<'src> Parser<'src> { options, source, errors: Vec::new(), + deferred_starred_unpacking_error: None, unsupported_syntax_errors: Vec::new(), tokens, name_interner: NameInterner::default(), @@ -259,6 +265,14 @@ impl<'src> Parser<'src> { ); // TODO consider re-integrating lexical error handling into the parser? let mut parse_errors = self.errors; + if !parse_errors.is_empty() + && let Some(error) = self.deferred_starred_unpacking_error + { + // CPython retries its invalid grammar rules only after a parse failure. + // Preserve encounter order, including nested operands whose ranges begin + // after the outer expression's later error. Semantic errors occur later. + parse_errors.insert(0, error); + } let (tokens, lex_errors) = self.tokens.finish(); // Fast path for when there are no lex errors. @@ -1130,22 +1144,27 @@ impl<'src> Parser<'src> { { // Only `from ... import` names before the newline and `with` items before the `:` have // a rule for a trailing comma. Otherwise parsing fails at the token after the comma. - let (message, range) = match recovery_context_kind { + let (error, range) = match recovery_context_kind { RecoveryContextKind::ImportFromAsNames(Parenthesized::No) if self.at(TokenKind::Newline) => { ( - "trailing comma not allowed without surrounding parentheses", + ParseErrorType::OtherError( + "trailing comma not allowed without surrounding parentheses" + .to_string(), + ), self.current_token_range(), ) } - RecoveryContextKind::WithItems(_) if self.at(TokenKind::Colon) => ( - "the last 'with' item has a trailing comma", - trailing_comma_range, + RecoveryContextKind::WithItems(_) if self.at(TokenKind::Colon) => { + (ParseErrorType::TrailingCommaInWith, trailing_comma_range) + } + _ => ( + ParseErrorType::OtherError("invalid syntax".to_string()), + self.current_token_range(), ), - _ => ("invalid syntax", self.current_token_range()), }; - self.add_error(ParseErrorType::OtherError(message.to_string()), range); + self.add_error(error, range); } self.recovery_context = saved_context; @@ -1169,6 +1188,7 @@ impl<'src> Parser<'src> { ParserCheckpoint { tokens: self.tokens.checkpoint(), errors_position: self.errors.len(), + had_deferred_starred_unpacking_error: self.deferred_starred_unpacking_error.is_some(), unsupported_syntax_errors_position: self.unsupported_syntax_errors.len(), current_token_id: self.current_token_id, prev_token_end: self.prev_token_end, @@ -1181,6 +1201,7 @@ impl<'src> Parser<'src> { let ParserCheckpoint { tokens, errors_position, + had_deferred_starred_unpacking_error, unsupported_syntax_errors_position, current_token_id, prev_token_end, @@ -1189,6 +1210,9 @@ impl<'src> Parser<'src> { self.tokens.rewind(tokens); self.errors.truncate(errors_position); + if !had_deferred_starred_unpacking_error { + self.deferred_starred_unpacking_error = None; + } self.unsupported_syntax_errors .truncate(unsupported_syntax_errors_position); self.current_token_id = current_token_id; @@ -1267,6 +1291,7 @@ impl IpyEscapeContext { struct ParserCheckpoint { tokens: TokenSourceCheckpoint, errors_position: usize, + had_deferred_starred_unpacking_error: bool, unsupported_syntax_errors_position: usize, current_token_id: TokenId, prev_token_end: TextSize, @@ -2056,7 +2081,10 @@ fn prioritize_tokenizer_error( let mut lexer = Lexer::new(source, mode, start_offset); // The starts of the tokens at which parsing stops without reading ahead. let mut stops = Vec::new(); + let mut replacement_hint = ReplacementFieldHint::new(first.as_ref()); + let mut replacement_hint_wins = false; let (tokenizer_error, in_interpolated_string, detected) = loop { + let replacement_field = lexer.replacement_field(); let in_interpolated_string = lexer.in_interpolated_string(); let previous_errors = lexer.errors().len(); let kind = lexer.next_token(); @@ -2064,21 +2092,26 @@ fn prioritize_tokenizer_error( .iter() .find(|error| error.error().is_tokenizer_error()) { + replacement_hint_wins = + replacement_hint.is_unclosed_field_hint(error.error(), replacement_field); // A missing closing brace or a bracket that closes a replacement field is found at // the token that shows it. let detected = match error.error() { LexicalErrorType::FStringError( InterpolatedStringErrorType::UnclosedLbrace + | InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } | InterpolatedStringErrorType::UnmatchedBracket(_), ) | LexicalErrorType::TStringError( InterpolatedStringErrorType::UnclosedLbrace + | InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } | InterpolatedStringErrorType::UnmatchedBracket(_), ) => error.location().start(), _ => lexer.offset(), }; break (error.clone(), in_interpolated_string, detected); } + replacement_hint.token(kind, lexer.current_range(), replacement_field); if matches!( kind, TokenKind::Colon | TokenKind::Semi | TokenKind::Unknown @@ -2086,6 +2119,16 @@ fn prioritize_tokenizer_error( stops.push(lexer.current_range().start()); } if kind == TokenKind::EndOfFile { + if let Some(start) = replacement_hint.unclosed_format_spec { + let error = LexicalError::new( + LexicalErrorType::UnclosedBracket { + opening: '{', + incomplete: false, + }, + TextRange::empty(start), + ); + break (error, false, lexer.offset()); + } return; } }; @@ -2094,7 +2137,12 @@ fn prioritize_tokenizer_error( move_to_front(errors, ParseError::from(tokenizer_error)); return; }; - let first_start = first.location.start(); + let first_start = match &first.error { + ParseErrorType::MissingCommaAfterConcatenatedLiteral { + expression_range, .. + } => expression_range.start(), + _ => first.location.start(), + }; let error_start = match tokenizer_error.error() { LexicalErrorType::UnclosedBracket { .. } => { unclosed_bracket_recovery.map_or(first_start, |start| start.max(first_start)) @@ -2109,7 +2157,8 @@ fn prioritize_tokenizer_error( LexicalErrorType::UnicodeEscapeError { .. } | LexicalErrorType::BytesEscapeError { .. } ) ); - let wins = detected <= first_start + let wins = replacement_hint_wins + || detected <= first_start || (!in_interpolated_string && !escape_error && match tokenizer_error.error() { @@ -2146,6 +2195,91 @@ fn prioritize_tokenizer_error( move_to_front(errors, ParseError::from(front)); } +/// Tracks the grammar boundaries after an expression hint while the unrecovered token stream +/// is read. A replacement field keeps reading to its separator; an inner display or a debug / +/// conversion separator can already establish the hint before a later missing brace is found. +struct ReplacementFieldHint { + range: Option, + field: Option, + stopped: bool, + unclosed_format_spec: Option, +} +impl ReplacementFieldHint { + fn new(first: Option<&ParseError>) -> Self { + let range = first.and_then(|error| match &error.error { + ParseErrorType::MissingCommaAfterConcatenatedLiteral { + expression_range, .. + } => Some(*expression_range), + ParseErrorType::OtherError(message) + if matches!( + message.as_str(), + "invalid syntax. Perhaps you forgot a comma?" + | "invalid syntax. Is this intended to be part of the string?" + ) => + { + Some(error.location) + } + _ => None, + }); + Self { + range, + field: None, + stopped: false, + unclosed_format_spec: None, + } + } + + fn token(&mut self, kind: TokenKind, range: TextRange, field: Option<(TextSize, bool)>) { + let Some(hint) = self.range else { + return; + }; + if range.contains(hint.start()) { + self.field = field.map(|(start, _)| start); + } + if range.start() < hint.end() || self.field.is_none() { + return; + } + // The hint covers both expressions. A later expression token establishes the hint; + // only the format-specifier and string-end tokens still belong to this field. + if !kind.is_trivia() + && !matches!( + kind, + TokenKind::Colon + | TokenKind::FStringMiddle + | TokenKind::TStringMiddle + | TokenKind::FStringEnd + | TokenKind::TStringEnd + | TokenKind::EndOfFile + ) + { + self.stopped = true; + } + if kind.is_interpolated_string_end() && !self.stopped { + self.unclosed_format_spec = field + .filter(|(start, format)| *format && Some(*start) == self.field) + .map(|(start, _)| start); + } + } + + fn is_unclosed_field_hint( + &self, + error: &LexicalErrorType, + field: Option<(TextSize, bool)>, + ) -> bool { + self.field.is_some() + && self.field == field.map(|(start, _)| start) + && !self.stopped + && matches!( + error, + LexicalErrorType::FStringError( + InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } + ) | LexicalErrorType::TStringError( + InterpolatedStringErrorType::UnclosedLbraceOnLine { .. } + ) + ) + } +} + /// Moves `error` to the front of `errors`, removing an equal error elsewhere. fn move_to_front(errors: &mut Vec, error: ParseError) { errors.retain(|other| *other != error); diff --git a/crates/ruff_python_parser/src/parser/pattern.rs b/crates/ruff_python_parser/src/parser/pattern.rs index b27b1e5b13693..241740609ffc3 100644 --- a/crates/ruff_python_parser/src/parser/pattern.rs +++ b/crates/ruff_python_parser/src/parser/pattern.rs @@ -212,6 +212,8 @@ impl Parser<'_> { self.parse_comma_separated_list(RecoveryContextKind::MatchPatternMapping, |parser| { let mapping_item_start = parser.node_start(); + let mapping_item_token = parser.current_token_range(); + let mapping_item_errors_start = parser.errors.len(); if parser.eat(TokenKind::DoubleStar) { // A `_` rest target is not a capture target. @@ -267,17 +269,20 @@ impl Parser<'_> { }; keys.push(key); + let colon_errors_start = parser.errors.len(); parser.expect(TokenKind::Colon); + if rest.is_some() && parser.errors.len() > colon_errors_start { + // An incomplete mapping entry does not match the invalid rest-order + // production. Report its first token, where the mapping stopped. + parser.errors[colon_errors_start].location = mapping_item_token; + } patterns.push(parser.parse_match_pattern(AllowStarPattern::No)); - if rest.is_some() { - parser.add_error( - ParseErrorType::OtherError( - "pattern cannot follow a double star pattern".to_string(), - ), - parser.node_range(mapping_item_start), - ); + if let Some(rest) = &rest + && parser.errors.len() == mapping_item_errors_start + { + parser.add_error(ParseErrorType::MappingRestPatternNotLast, rest); } } }); diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 57b24a572e4c9..201d72c0ff3d7 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -111,8 +111,9 @@ impl<'src> Parser<'src> { /// - pub(super) fn parse_statement(&mut self) -> Stmt { let start = self.node_start(); + let case_diagnostic = self.standalone_case_diagnostic(); - match self.current_token_kind() { + let statement = match self.current_token_kind() { TokenKind::If => Stmt::If(self.parse_if_statement()), TokenKind::For => Stmt::For(self.parse_for_statement(start)), TokenKind::While => Stmt::While(self.parse_while_statement()), @@ -146,7 +147,16 @@ impl<'src> Parser<'src> { self.parse_single_simple_statement() } + }; + if !self.errors.is_empty() + && let Some(error) = case_diagnostic + { + // Only the invalid-grammar diagnostic changes. Keep the ordinary + // statement parser's AST, tokens, and recovery path intact. + self.errors.insert(0, error); } + + statement } /// Parses a single simple statement. @@ -574,9 +584,7 @@ impl<'src> Parser<'src> { // raise from exc // raise from None self.add_error( - ParseErrorType::OtherError( - "did you forget an expression between 'raise' and 'from'?".to_string(), - ), + ParseErrorType::MissingRaiseException, raise_range.cover(self.current_token_range()), ); None @@ -609,12 +617,7 @@ impl<'src> Parser<'src> { if exc.is_some() && !self.at_expr() { // test_err raise_stmt_missing_cause // raise x from - self.add_error( - ParseErrorType::OtherError( - "did you forget an expression after 'from'?".to_string(), - ), - from_range, - ); + self.add_error(ParseErrorType::MissingRaiseCause, from_range); } // test_err raise_stmt_invalid_cause @@ -800,13 +803,10 @@ impl<'src> Parser<'src> { // from .mod lazy import join // from ..mod lazy import join // from ...mod lazy import join - if self.at(TokenKind::Lazy) && self.peek() == TokenKind::Import { - self.add_error( - ParseErrorType::OtherError( - "use 'lazy from ... ' instead of 'from ... lazy import'".to_string(), - ), - self.current_token_range(), - ); + if module.is_some() + && let Some(range) = self.misplaced_lazy_import_range() + { + self.add_error(ParseErrorType::MisplacedLazyImport, range); self.bump(TokenKind::Lazy); } @@ -888,6 +888,39 @@ impl<'src> Parser<'src> { } } + /// Checks the misplaced `lazy` invalid production with the import-target parser. + /// Rewind afterward so the existing invalid-input recovery and AST stay intact. + fn misplaced_lazy_import_range(&mut self) -> Option { + if !self.at(TokenKind::Lazy) || self.peek() != TokenKind::Import { + return None; + } + let checkpoint = self.checkpoint(); + let unclosed_bracket = self.first_unclosed_bracket_recovery; + let escape_error = self.interpolated_string_escape_error.clone(); + let replacement_field = self.replacement_field; + let errors_before = self.errors.len(); + let lazy_range = self.current_token_range(); + self.bump(TokenKind::Lazy); + self.bump(TokenKind::Import); + let parenthesized = Parenthesized::from(self.eat(TokenKind::Lpar)); + let names = self.parse_comma_separated_list_into_vec( + RecoveryContextKind::ImportFromAsNames(parenthesized), + |parser| parser.parse_alias(ImportStyle::ImportFrom), + ); + if parenthesized.is_yes() { + self.expect(TokenKind::Rpar); + } + let valid = !names.is_empty() + && self.errors.len() == errors_before + && (!names.iter().any(|alias| alias.name.id == "*") + || (names.len() == 1 && !parenthesized.is_yes())); + self.rewind(checkpoint); + self.first_unclosed_bracket_recovery = unclosed_bracket; + self.interpolated_string_escape_error = escape_error; + self.replacement_field = replacement_field; + valid.then_some(lazy_range) + } + /// Parses an `import` or `from` import name. /// /// See: @@ -1068,7 +1101,11 @@ impl<'src> Parser<'src> { // assert assert x // assert yield x // assert x := 1 + let test_errors_start = self.errors.len(); let test = self.parse_conditional_expression_or_higher(); + if self.errors.len() == test_errors_start { + self.check_assert_assignment(&test); + } let msg = if self.eat(TokenKind::Comma) { if self.at_expr() { @@ -1077,7 +1114,12 @@ impl<'src> Parser<'src> { // assert False, assert x // assert False, yield x // assert False, x := 1 - Some(Box::new(self.parse_conditional_expression_or_higher().expr)) + let message_errors_start = self.errors.len(); + let message = self.parse_conditional_expression_or_higher(); + if self.errors.len() == message_errors_start { + self.check_assert_assignment(&message); + } + Some(Box::new(message.expr)) } else { // test_err assert_empty_msg // assert x, @@ -1099,6 +1141,51 @@ impl<'src> Parser<'src> { } } + /// Diagnose an assignment after an otherwise valid assertion expression. Speculative + /// parsing supplies the expression span without consuming recovery tokens or changing + /// the recovered assertion AST. Errors inside parentheses retain their own diagnostics. + fn check_assert_assignment(&mut self, target: &ParsedExpr) { + let operator = self.current_token_kind(); + if !matches!(operator, TokenKind::Equal | TokenKind::ColonEqual) { + return; + } + + let checkpoint = self.checkpoint(); + // Diagnostic speculation must not retain recovery state from its RHS. + let unclosed_bracket = self.first_unclosed_bracket_recovery; + let escape_error = self.interpolated_string_escape_error.clone(); + let replacement_field = self.replacement_field; + let errors_start = self.errors.len(); + self.bump(operator); + let value_end = if self.at_expr() { + let value = self.parse_conditional_expression_or_higher(); + (self.errors.len() == errors_start + && !self.tokens.has_tokenizer_error_in(TextRange::new( + target.start(), + self.current_token_range().end(), + ))) + .then_some(value.end()) + } else { + None + }; + self.rewind(checkpoint); + self.first_unclosed_bracket_recovery = unclosed_bracket; + self.interpolated_string_escape_error = escape_error; + self.replacement_field = replacement_field; + + if let Some(value_end) = value_end { + let error = if operator == TokenKind::ColonEqual { + ParseErrorType::NamedExpressionWithoutParentheses + } else { + ParseErrorType::InvalidAssignmentTarget { + kind: ExpressionKind::of(&target.expr), + maybe_comparison: true, + } + }; + self.add_error(error, TextRange::new(target.start(), value_end)); + } + } + /// Parses a global statement. /// /// # Panics @@ -3411,7 +3498,7 @@ impl<'src> Parser<'src> { /// clause header or an indented block. /// /// `header_start` is the start of the clause's header keyword token. - fn parse_body(&mut self, clause: BlockClause, header_start: TextSize) -> Suite { + pub(super) fn parse_body(&mut self, clause: BlockClause, header_start: TextSize) -> Suite { // Note: The test cases in this method chooses a clause at random to test // the error logic. @@ -3738,9 +3825,7 @@ impl<'src> Parser<'src> { // def foo(a, *args1, *args2, b): ... // def foo(a, *args1, b, c, *args2): ... parser.add_error( - ParseErrorType::OtherError( - "* argument may appear only once".to_string(), - ), + ParseErrorType::OtherError("* may appear only once".to_string()), star_range, ); } @@ -3760,7 +3845,7 @@ impl<'src> Parser<'src> { // def foo(a, *args, *, b): ... // def foo(a, *args, b, c, *): ... let message = if parser.at(TokenKind::Comma) { - "* argument may appear only once" + "* may appear only once" } else { "invalid syntax" }; @@ -3846,7 +3931,7 @@ impl<'src> Parser<'src> { // def foo(/, a): ... parser.add_error( ParseErrorType::OtherError( - "at least one argument must precede /".to_string(), + "at least one parameter must precede /".to_string(), ), slash_range, ); diff --git a/crates/ruff_python_parser/tests/python315_diagnostics.rs b/crates/ruff_python_parser/tests/python315_diagnostics.rs new file mode 100644 index 0000000000000..b346aee481568 --- /dev/null +++ b/crates/ruff_python_parser/tests/python315_diagnostics.rs @@ -0,0 +1,89 @@ +use ruff_python_ast::PythonVersion; +use ruff_python_parser::{Mode, ParseOptions, parse_unchecked}; + +#[test] +fn specific_diagnostics() { + for (source, message) in [ + ( + "assert x = 1", + "cannot assign to name here. Maybe you meant '==' instead of '='?", + ), + ( + "assert False, x = 1", + "cannot assign to name here. Maybe you meant '==' instead of '='?", + ), + ( + "assert x := 1", + "cannot use named expression without parentheses here", + ), + ( + "case 1:\n pass", + "case statement must be inside match statement", + ), + ("case 1:", "expected an indented block"), + ( + "case 1:\n raise from None", + "did you forget an expression between 'raise' and 'from'?", + ), + ( + "case {_.x: value}:\n pass", + "case statement must be inside match statement", + ), + ( + "value = f\"\"\"{\n1\n\"\"\"", + "f-string: expecting '}' to close '{' on line 1", + ), + ( + "value = t\"\"\"{\n1\n\"\"\"", + "t-string: expecting '}' to close '{' on line 1", + ), + ( + "value = f\"\"\"{0:\n{1\n\"\"\"", + "f-string: expecting '}' to close '{' on line 2", + ), + ] { + let parsed = parse_unchecked( + source, + ParseOptions::from(Mode::Module).with_target_version(PythonVersion::PY315), + ); + assert_eq!(parsed.errors()[0].error.to_string(), message, "{source}"); + } +} + +#[test] +fn corrected_locations() { + let source = "match x:\n case {**rest, 1: value}: pass"; + let parsed = parse_unchecked(source, Mode::Module.into()); + let error = &parsed.errors()[0]; + assert_eq!( + error.error.to_string(), + "double star pattern must be the last (right-most) subpattern in the mapping pattern" + ); + assert_eq!(&source[error.location], "rest"); + + for prefix in ["", "b", "f", "t"] { + let source = format!("print(\n {prefix}\"first\"\n {prefix}\"last\"\n x=1\n)"); + let parsed = parse_unchecked( + &source, + ParseOptions::from(Mode::Module).with_target_version(PythonVersion::PY315), + ); + let error = &parsed.errors()[0]; + assert_eq!( + error.error.to_string(), + "invalid syntax. Perhaps you forgot a comma?" + ); + assert_eq!(&source[error.location], format!("{prefix}\"last\"")); + } +} + +#[test] +fn diagnostic_probes_preserve_valid_syntax() { + for source in ["assert (x := 1)", "assert True, (message := 2)", "case(1)"] { + let parsed = parse_unchecked(source, Mode::Module.into()); + assert!( + parsed.errors().is_empty(), + "{source}: {:?}", + parsed.errors() + ); + } +} diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_msg_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_msg_expr.py.snap index 5d1a26299d08a..4018715a68bfb 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_msg_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_msg_expr.py.snap @@ -171,6 +171,13 @@ Module( | + | +2 | assert False, assert x +3 | assert False, yield x +4 | assert False, x := 1 + | ^^^^^^ Syntax Error: cannot use named expression without parentheses here + + | 2 | assert False, assert x 3 | assert False, yield x diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_test_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_test_expr.py.snap index eac8e83a2f87f..59545537cde31 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_test_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@assert_invalid_test_expr.py.snap @@ -156,6 +156,13 @@ Module( | + | +2 | assert assert x +3 | assert yield x +4 | assert x := 1 + | ^^^^^^ Syntax Error: cannot use named expression without parentheses here + + | 2 | assert assert x 3 | assert yield x diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap index f97bc98dee241..be2ebf3b15fae 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__double_star_comprehension.py.snap @@ -96,7 +96,7 @@ Module( 2 | # it's actually a comprehension. 3 | 4 | {**x: y for x, y in data} - | ^ Syntax Error: invalid syntax + | ^^^ Syntax Error: cannot use dict unpacking in a dictionary key 5 | 6 | # TODO(dhruvmanila): This test case fails because there's no way to represent `**y` | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__recover.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__recover.py.snap index 796fb7a196f4f..9dbbe3646d288 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__recover.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__dict__recover.py.snap @@ -554,7 +554,7 @@ Module( | 22 | # Star expression not allowed here 23 | {*x: y, z: a, *b: c} - | ^ Syntax Error: invalid syntax + | ^^ Syntax Error: cannot use a starred expression in a dictionary key 24 | {x: *y, z: *a} | @@ -562,7 +562,7 @@ Module( | 22 | # Star expression not allowed here 23 | {*x: y, z: a, *b: c} - | ^ Syntax Error: invalid syntax + | ^^ Syntax Error: cannot use a starred expression in a dictionary key 24 | {x: *y, z: *a} | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__if__recover.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__if__recover.py.snap index 9121efdb7195d..722a421d11a2f 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__if__recover.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__if__recover.py.snap @@ -388,7 +388,7 @@ Module( | 7 | # Invalid orelse expression 8 | x if expr else *orelse - | ^ Syntax Error: expected expression after 'else', but statement is given + | ^ Syntax Error: cannot unpack only part of a conditional expression 9 | x if expr else yield y 10 | x if expr else yield from y | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__lambda_duplicate_parameters.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__lambda_duplicate_parameters.py.snap index 8cf86cd9ed3f8..6571dbf11a18b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__lambda_duplicate_parameters.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__lambda_duplicate_parameters.py.snap @@ -364,7 +364,7 @@ Module( 7 | lambda a, *a: 1 8 | 9 | lambda a, *, **a: 1 - | ^^ Syntax Error: named arguments must follow bare * + | ^^ Syntax Error: named parameters must follow bare * ## Semantic Syntax Errors diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap index 91fe18e3f438a..f6fbe8d17b047 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__comprehension.py.snap @@ -488,27 +488,12 @@ Module( ExprListComp { node_index: NodeIndex(None), range: 193..216, - elt: BinOp( - ExprBinOp { + elt: Name( + ExprName { node_index: NodeIndex(None), - range: 194..197, - left: Name( - ExprName { - node_index: NodeIndex(None), - range: 194..194, - id: Name(""), - ctx: Invalid, - }, - ), - op: Pow, - right: Name( - ExprName { - node_index: NodeIndex(None), - range: 196..197, - id: Name("x"), - ctx: Load, - }, - ), + range: 196..197, + id: Name("x"), + ctx: Load, }, ), generators: [ @@ -1224,7 +1209,7 @@ Module( 10 | [x for x in yield from y] 11 | [x for x in lambda y: y] 12 | [**x for x in [{1: 2}]] - | ^^ Syntax Error: invalid syntax + | ^^^ Syntax Error: cannot use dict unpacking in list comprehension 13 | [*x, for x in y] 14 | [*x, *y for x in y] | @@ -1352,7 +1337,7 @@ Module( 19 | [x for x in data if yield from y] 20 | [x for x in data if lambda y: y] 21 | [*x if x else y for x in z] - | ^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 22 | [x if x else *y for x in z] | @@ -1361,7 +1346,7 @@ Module( 20 | [x for x in data if lambda y: y] 21 | [*x if x else y for x in z] 22 | [x if x else *y for x in z] - | ^ Syntax Error: expected expression after 'else', but statement is given + | ^ Syntax Error: cannot unpack only part of a conditional expression ## Unsupported Syntax Errors diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap index c77c0e1f75344..67acf0aa7364a 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__list__star_expression_precedence.py.snap @@ -517,7 +517,7 @@ Module( 6 | [*x and y, z] 7 | [*x or y, z] 8 | [*x if True else y, z] - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 9 | [*lambda x: x, z] 10 | [*x := 2, z] | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap index 1b60f04ed2209..9dd5376bf8399 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__parenthesized__tuple_starred_expr.py.snap @@ -1403,7 +1403,7 @@ Module( 6 | (*x and y, z, *x and y) 7 | (*x or y, z, *x or y) 8 | (*x if True else y, z, *x if True else y) - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 9 | (*lambda x: x, z, *lambda x: x) 10 | (*x := 2, z, *x := 2) | @@ -1413,7 +1413,7 @@ Module( 6 | (*x and y, z, *x and y) 7 | (*x or y, z, *x or y) 8 | (*x if True else y, z, *x if True else y) - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 9 | (*lambda x: x, z, *lambda x: x) 10 | (*x := 2, z, *x := 2) | @@ -1533,7 +1533,7 @@ Module( 16 | *x and y, z, *x and y 17 | *x or y, z, *x or y 18 | *x if True else y, z, *x if True else y - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 19 | *lambda x: x, z, *lambda x: x 20 | *x := 2, z, *x := 2 | @@ -1543,7 +1543,7 @@ Module( 16 | *x and y, z, *x and y 17 | *x or y, z, *x or y 18 | *x if True else y, z, *x if True else y - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 19 | *lambda x: x, z, *lambda x: x 20 | *x := 2, z, *x := 2 | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap index 04dd3fcf4b519..f37ae52729f86 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__set__star_expression_precedence.py.snap @@ -509,7 +509,7 @@ Module( 6 | {*x and y, z} 7 | {*x or y, z} 8 | {*x if True else y, z} - | ^^^^^^^^^^^^^^^^ Syntax Error: conditional expression cannot be used here + | ^^^^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? 9 | {*lambda x: x, z} 10 | {*x := 2, z} | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_expected_after_star_separator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_expected_after_star_separator.py.snap index 175c0be3a5043..450b8535f660f 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_expected_after_star_separator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_expected_after_star_separator.py.snap @@ -283,7 +283,7 @@ Module( | 1 | def foo(*): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * 2 | def foo(*,): ... 3 | def foo(a, *): ... | @@ -292,7 +292,7 @@ Module( | 1 | def foo(*): ... 2 | def foo(*,): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * 3 | def foo(a, *): ... 4 | def foo(a, *,): ... | @@ -302,7 +302,7 @@ Module( 1 | def foo(*): ... 2 | def foo(*,): ... 3 | def foo(a, *): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * 4 | def foo(a, *,): ... 5 | def foo(*, **kwargs): ... | @@ -312,7 +312,7 @@ Module( 2 | def foo(*,): ... 3 | def foo(a, *): ... 4 | def foo(a, *,): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * 5 | def foo(*, **kwargs): ... | @@ -321,4 +321,4 @@ Module( 3 | def foo(a, *): ... 4 | def foo(a, *,): ... 5 | def foo(*, **kwargs): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_follows_var_keyword_param.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_follows_var_keyword_param.py.snap index 1de5be6107bdd..92949bcda60c6 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_follows_var_keyword_param.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_follows_var_keyword_param.py.snap @@ -134,24 +134,24 @@ Module( | 1 | def foo(**kwargs, a, /, b=10, *, *args): ... - | ^ Syntax Error: arguments cannot follow var-keyword argument + | ^ Syntax Error: parameters cannot follow var-keyword parameter | 1 | def foo(**kwargs, a, /, b=10, *, *args): ... - | ^ Syntax Error: arguments cannot follow var-keyword argument + | ^ Syntax Error: parameters cannot follow var-keyword parameter | 1 | def foo(**kwargs, a, /, b=10, *, *args): ... - | ^ Syntax Error: arguments cannot follow var-keyword argument + | ^ Syntax Error: parameters cannot follow var-keyword parameter | 1 | def foo(**kwargs, a, /, b=10, *, *args): ... - | ^ Syntax Error: arguments cannot follow var-keyword argument + | ^ Syntax Error: parameters cannot follow var-keyword parameter | 1 | def foo(**kwargs, a, /, b=10, *, *args): ... - | ^ Syntax Error: arguments cannot follow var-keyword argument + | ^ Syntax Error: parameters cannot follow var-keyword parameter diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_kwarg_after_star_separator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_kwarg_after_star_separator.py.snap index 78f55dffc2357..1fec8cba964c6 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_kwarg_after_star_separator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_kwarg_after_star_separator.py.snap @@ -75,4 +75,4 @@ Module( | 1 | def foo(*, **kwargs): ... - | ^ Syntax Error: named arguments must follow bare * + | ^ Syntax Error: named parameters must follow bare * diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_kwargs.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_kwargs.py.snap index 0e368d1126d17..6b3c0d9b4f0e3 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_kwargs.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_kwargs.py.snap @@ -93,4 +93,4 @@ Module( | 1 | def foo(a, **kwargs1, **kwargs2): ... - | ^^ Syntax Error: arguments cannot follow var-keyword argument + | ^^ Syntax Error: parameters cannot follow var-keyword parameter diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_star_separator.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_star_separator.py.snap index 0eae1926d14b3..d7ec86b6aec7b 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_star_separator.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_star_separator.py.snap @@ -194,7 +194,7 @@ Module( | 1 | def foo(a, *, *, b): ... - | ^ Syntax Error: * argument may appear only once + | ^ Syntax Error: * may appear only once 2 | def foo(a, *, b, c, *): ... | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_varargs.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_varargs.py.snap index 3bbb3eb982e0d..c78511b1435b4 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_varargs.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_multiple_varargs.py.snap @@ -312,7 +312,7 @@ Module( | 1 | def foo(a, *, *args, b): ... - | ^ Syntax Error: * argument may appear only once + | ^ Syntax Error: * may appear only once 2 | # def foo(a, *, b, c, *args): ... 3 | def foo(a, *args1, *args2, b): ... | @@ -322,7 +322,7 @@ Module( 1 | def foo(a, *, *args, b): ... 2 | # def foo(a, *, b, c, *args): ... 3 | def foo(a, *args1, *args2, b): ... - | ^ Syntax Error: * argument may appear only once + | ^ Syntax Error: * may appear only once 4 | def foo(a, *args1, b, c, *args2): ... | @@ -331,4 +331,4 @@ Module( 2 | # def foo(a, *, b, c, *args): ... 3 | def foo(a, *args1, *args2, b): ... 4 | def foo(a, *args1, b, c, *args2): ... - | ^ Syntax Error: * argument may appear only once + | ^ Syntax Error: * may appear only once diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_no_arg_before_slash.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_no_arg_before_slash.py.snap index f7fa1f1799244..852309a02a5ff 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_no_arg_before_slash.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_no_arg_before_slash.py.snap @@ -123,7 +123,7 @@ Module( | 1 | def foo(/): ... - | ^ Syntax Error: at least one argument must precede / + | ^ Syntax Error: at least one parameter must precede / 2 | def foo(/, a): ... | @@ -131,4 +131,4 @@ Module( | 1 | def foo(/): ... 2 | def foo(/, a): ... - | ^ Syntax Error: at least one argument must precede / + | ^ Syntax Error: at least one parameter must precede / diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_star_separator_after_star_param.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_star_separator_after_star_param.py.snap index 79e4b1a4578bf..936d6b8de753a 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_star_separator_after_star_param.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_star_separator_after_star_param.py.snap @@ -220,7 +220,7 @@ Module( | 1 | def foo(a, *args, *, b): ... - | ^ Syntax Error: * argument may appear only once + | ^ Syntax Error: * may appear only once 2 | def foo(a, *args, b, c, *): ... | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap index aff252185e913..e53a1cf712ab0 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_keyword_with_default.py.snap @@ -176,7 +176,7 @@ Module( | 1 | def foo(a, **kwargs={'b': 1, 'c': 2}): ... - | ^ Syntax Error: var-keyword argument cannot have default value + | ^ Syntax Error: var-keyword parameter cannot have default value | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_positional_with_default.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_positional_with_default.py.snap index b3111adddb860..ca8ba79796a74 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_positional_with_default.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@params_var_positional_with_default.py.snap @@ -128,7 +128,7 @@ Module( | 1 | def foo(a, *args=(1, 2)): ... - | ^ Syntax Error: var-positional argument cannot have default value + | ^ Syntax Error: var-positional parameter cannot have default value | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_double_starred_expr.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_double_starred_expr.py.snap index bc0ad8f573c90..cc5ea2401ec41 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_double_starred_expr.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_double_starred_expr.py.snap @@ -33,4 +33,4 @@ Module( | 1 | (**x) - | ^^ Syntax Error: cannot use double starred expression here + | ^^ Syntax Error: cannot use dict unpacking here diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap index 5a2c48211dcd0..780087a3fcb04 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@pep_798_invalid_dict_unpacking_comprehensions_py315.py.snap @@ -327,7 +327,7 @@ Module( | 1 | # parse_options: {"target-version": "3.15"} 2 | {*k: v for k, v in items} - | ^ Syntax Error: invalid syntax + | ^^ Syntax Error: cannot use a starred expression in a dictionary key 3 | {k: *v for k, v in items} 4 | {**k: v for k, v in items} | @@ -347,7 +347,7 @@ Module( 2 | {*k: v for k, v in items} 3 | {k: *v for k, v in items} 4 | {**k: v for k, v in items} - | ^ Syntax Error: invalid syntax + | ^^^ Syntax Error: cannot use dict unpacking in a dictionary key 5 | {k: **v for k, v in items} | @@ -356,4 +356,4 @@ Module( 3 | {k: *v for k, v in items} 4 | {**k: v for k, v in items} 5 | {k: **v for k, v in items} - | ^ Syntax Error: invalid syntax + | ^^^ Syntax Error: cannot use dict unpacking in a dictionary value diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__invalid_mapping_pattern.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__invalid_mapping_pattern.py.snap index 16d6cf482be8c..05c6e2fb88e1f 100644 --- a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__invalid_mapping_pattern.py.snap +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@statements__match__invalid_mapping_pattern.py.snap @@ -625,7 +625,7 @@ Module( 13 | # Multiple double star patterns are not allowed 14 | match subject: 15 | case {**rest, None: 1}: - | ^^^^^^^ Syntax Error: pattern cannot follow a double star pattern + | ^^^^ Syntax Error: double star pattern must be the last (right-most) subpattern in the mapping pattern 16 | pass 17 | case {**rest1, **rest2, None: 1}: | @@ -645,7 +645,7 @@ Module( 15 | case {**rest, None: 1}: 16 | pass 17 | case {**rest1, **rest2, None: 1}: - | ^^^^^^^ Syntax Error: pattern cannot follow a double star pattern + | ^^^^^ Syntax Error: double star pattern must be the last (right-most) subpattern in the mapping pattern 18 | pass 19 | case {**rest1, None: 1, **rest2}: | @@ -655,7 +655,7 @@ Module( 17 | case {**rest1, **rest2, None: 1}: 18 | pass 19 | case {**rest1, None: 1, **rest2}: - | ^^^^^^^ Syntax Error: pattern cannot follow a double star pattern + | ^^^^^ Syntax Error: double star pattern must be the last (right-most) subpattern in the mapping pattern 20 | pass |