From e72c1058999264cad17a01119a6fa57b3946a493 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Mon, 19 Oct 2026 22:26:24 +0900 Subject: [PATCH 1/5] Support full PEP 798 unpacking operands and diagnostics Parse full expression operands in dictionary and starred generator comprehensions. Preserve conditional-unpacking fallback ordering and recover malformed dictionary unpacking without invalid AST ranges. Add parser fixtures and regenerated snapshots. Assisted-by: OpenAI Codex:model version unavailable --- .../expressions/pep798_boolean_then_error.py | 2 + .../pep798_call_conditional_then_error.py | 2 + .../pep798_comparison_then_error.py | 2 + .../expressions/pep798_conditional_display.py | 5 + .../pep798_conditional_then_error.py | 2 + .../expressions/pep798_dict_positions.py | 8 + .../expressions/pep798_lambda_then_error.py | 2 + .../expressions/pep798_missing_operand.py | 5 + .../pep798_nested_conditional_then_error.py | 2 + .../expressions/pep798_nested_operand.py | 3 + .../expressions/pep798_nested_then_error.py | 2 + .../pep798_soft_keyword_operand.py | 4 + .../expressions/pep798_unpacking_operands.py | 16 + crates/ruff_python_parser/src/error.rs | 55 + .../src/parser/expression.rs | 366 ++++-- crates/ruff_python_parser/src/parser/mod.rs | 19 + ...s__dict__double_star_comprehension.py.snap | 2 +- ..._syntax@expressions__dict__recover.py.snap | 4 +- ...id_syntax@expressions__if__recover.py.snap | 2 +- ...x@expressions__list__comprehension.py.snap | 31 +- ...__list__star_expression_precedence.py.snap | 2 +- ..._parenthesized__tuple_starred_expr.py.snap | 8 +- ...essions__pep798_boolean_then_error.py.snap | 122 ++ ...pep798_call_conditional_then_error.py.snap | 155 +++ ...ions__pep798_comparison_then_error.py.snap | 124 ++ ...ssions__pep798_conditional_display.py.snap | 251 ++++ ...ons__pep798_conditional_then_error.py.snap | 132 ++ ...expressions__pep798_dict_positions.py.snap | 448 +++++++ ...ressions__pep798_lambda_then_error.py.snap | 111 ++ ...xpressions__pep798_missing_operand.py.snap | 216 ++++ ...p798_nested_conditional_then_error.py.snap | 194 +++ ...expressions__pep798_nested_operand.py.snap | 176 +++ ...ressions__pep798_nested_then_error.py.snap | 162 +++ ...sions__pep798_soft_keyword_operand.py.snap | 114 ++ ...s__set__star_expression_precedence.py.snap | 2 +- ...@parenthesized_double_starred_expr.py.snap | 2 +- ...ict_unpacking_comprehensions_py315.py.snap | 6 +- ...essions__pep798_unpacking_operands.py.snap | 1079 +++++++++++++++++ 38 files changed, 3722 insertions(+), 116 deletions(-) create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_boolean_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_call_conditional_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_comparison_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_display.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_dict_positions.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_lambda_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_missing_operand.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_conditional_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_operand.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_then_error.py create mode 100644 crates/ruff_python_parser/resources/invalid/expressions/pep798_soft_keyword_operand.py create mode 100644 crates/ruff_python_parser/resources/valid/expressions/pep798_unpacking_operands.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_boolean_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_call_conditional_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_comparison_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_display.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_dict_positions.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_lambda_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_missing_operand.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_conditional_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_operand.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_then_error.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_soft_keyword_operand.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__pep798_unpacking_operands.py.snap diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_boolean_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_boolean_then_error.py new file mode 100644 index 0000000000000..206a96324116b --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_boolean_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*x or y for x in z); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_call_conditional_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_call_conditional_then_error.py new file mode 100644 index 0000000000000..ce10033963e37 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_call_conditional_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +f(*x if c else y for x in z); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_comparison_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_comparison_then_error.py new file mode 100644 index 0000000000000..a2e143184dd8e --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_comparison_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*x < y for x in z); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_display.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_display.py new file mode 100644 index 0000000000000..f4ec8092b101b --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_display.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +[*x if x else y] +{**x if x else y} +[x if x else *y] +{x if x else **y} diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_then_error.py new file mode 100644 index 0000000000000..0d6f6a4f79b2f --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*x if c else y for x in z); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_dict_positions.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_dict_positions.py new file mode 100644 index 0000000000000..3be40013ad192 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_dict_positions.py @@ -0,0 +1,8 @@ +# parse_options: {"target-version": "3.15"} +[**x for x in xs] +(**x for x in xs) +f(**x for x in xs) +{*x: y for x in xs} +{**x: y for x in xs} +{x: *y for x in xs} +{x: **y for x in xs} diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_lambda_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_lambda_then_error.py new file mode 100644 index 0000000000000..205b65bcaa0a7 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_lambda_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*lambda: x for x in z); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_missing_operand.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_missing_operand.py new file mode 100644 index 0000000000000..594562e9855f4 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_missing_operand.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +[**] +(**) +[**, x] +[** for x in z] diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_conditional_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_conditional_then_error.py new file mode 100644 index 0000000000000..4a2eefb51e8b1 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_conditional_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*(*x if c else y for x in xs) if d else z for z in zs); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_operand.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_operand.py new file mode 100644 index 0000000000000..f22c317d340ba --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_operand.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +[**(x if x else *y) for x in z] +(**(x if x else **y) for x in z) diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_then_error.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_then_error.py new file mode 100644 index 0000000000000..365882d87f3bb --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_then_error.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +(*(*x or y for x in z) for z in zs); a = diff --git a/crates/ruff_python_parser/resources/invalid/expressions/pep798_soft_keyword_operand.py b/crates/ruff_python_parser/resources/invalid/expressions/pep798_soft_keyword_operand.py new file mode 100644 index 0000000000000..d7b42bab4e0aa --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/expressions/pep798_soft_keyword_operand.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +[**match] +[**case] +[**type] diff --git a/crates/ruff_python_parser/resources/valid/expressions/pep798_unpacking_operands.py b/crates/ruff_python_parser/resources/valid/expressions/pep798_unpacking_operands.py new file mode 100644 index 0000000000000..f122216af6e04 --- /dev/null +++ b/crates/ruff_python_parser/resources/valid/expressions/pep798_unpacking_operands.py @@ -0,0 +1,16 @@ +# parse_options: {"target-version": "3.15"} +{**x if c else y for x in xs} +{**x or y for x in xs} +{**x < y for x in xs} +{**lambda: x for x in xs} +(*x if c else y for x in xs) +(*x or y for x in xs) +(*x < y for x in xs) +(*lambda: x for x in xs) +f(*x if c else y for x in xs) +f(*x if c else y) +(*(*x or y for x in z) for z in zs) +async def unpack(xs): + return {**x if c else y async for x in xs} +async def iterate(xs): + return (*x if c else y async for x in xs) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 1c5cdf39ed558..9da76b5d6eb2c 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -224,6 +224,18 @@ 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. @@ -538,6 +550,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") diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 595f906b1f913..062b51566591b 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -687,22 +687,33 @@ impl<'src> Parser<'src> { } } - /// 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 required by sequence and dictionary displays. + /// The caller can defer this check until a dictionary comprehension is ruled out. + 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(_) @@ -712,15 +723,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. @@ -1002,6 +1011,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; @@ -1009,8 +1019,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 @@ -2623,6 +2668,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 @@ -2655,6 +2797,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(), ); @@ -2751,7 +2896,8 @@ 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()); if matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For) { @@ -2762,13 +2908,23 @@ 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))), + )); } + self.validate_unpacking_precedence(&value, after_brace, true); + 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(); @@ -2778,6 +2934,7 @@ impl<'src> Parser<'src> { Some(value.expr), dict_value.expr, start, + None, )); } @@ -2837,7 +2994,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( @@ -2850,24 +3009,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( @@ -2912,32 +3061,20 @@ 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(), + ExpressionContext::yield_or_starred_bitwise_or() + .with_for_excluded() + .with_starred_generator_allowed(), ); match self.current_token_kind() { @@ -3161,17 +3298,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} @@ -3189,7 +3336,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, }); } @@ -3219,18 +3366,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; } @@ -3395,9 +3547,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 { @@ -3475,6 +3633,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( @@ -3485,9 +3644,32 @@ 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()); + let is_generator = self.errors.len() == errors_start + && context.is_starred_generator_allowed() + && matches!(self.current_token_kind(), TokenKind::Async | TokenKind::For); + if !is_generator { + 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), @@ -3730,13 +3912,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 @@ -3975,6 +4169,10 @@ 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; + + /// The first element after `(` may become a generator expression. Its starred + /// operand can use full expression precedence if a comprehension clause follows. + const ALLOW_STARRED_GENERATOR = 1 << 5; } } @@ -4047,6 +4245,16 @@ impl ExpressionContext { ExpressionContext(self.0 | ExpressionContextFlags::EXCLUDE_FOR) } + /// Allow the first parenthesized starred element to become a generator element. + fn with_starred_generator_allowed(self) -> Self { + ExpressionContext(self.0 | ExpressionContextFlags::ALLOW_STARRED_GENERATOR) + } + + const fn is_starred_generator_allowed(self) -> bool { + self.0 + .contains(ExpressionContextFlags::ALLOW_STARRED_GENERATOR) + } + /// Returns `true` if the `in` keyword should be excluded from a comparison expression. const fn is_in_excluded(self) -> bool { self.0.contains(ExpressionContextFlags::EXCLUDE_IN) diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index fdaab748df0ff..ce2c4371c25d6 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -82,6 +82,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, @@ -163,6 +167,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(), @@ -283,6 +288,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. @@ -1193,6 +1206,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, @@ -1205,6 +1219,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, @@ -1213,6 +1228,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; @@ -1244,6 +1262,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, 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__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__pep798_boolean_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_boolean_then_error.py.snap new file mode 100644 index 0000000000000..bb35a1a7e4357 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_boolean_then_error.py.snap @@ -0,0 +1,122 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_boolean_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..70, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..64, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..64, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..52, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 46..52, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 53..63, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 57..58, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 62..63, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 66..69, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 66..67, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 69..69, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*x or y for x in z); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_call_conditional_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_call_conditional_then_error.py.snap new file mode 100644 index 0000000000000..43339b5bb9121 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_call_conditional_then_error.py.snap @@ -0,0 +1,155 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_call_conditional_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..78, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..72, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..72, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..45, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 45..72, + node_index: NodeIndex(None), + args: [ + Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 46..71, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 46..60, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 47..60, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..53, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 47..48, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 59..60, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 61..71, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 65..66, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 70..71, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: false, + }, + ), + ], + keywords: [], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 74..77, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 74..75, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 77..77, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | f(*x if c else y for x in z); a = + | ^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | f(*x if c else y for x in z); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_comparison_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_comparison_then_error.py.snap new file mode 100644 index 0000000000000..1e96cd4e16378 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_comparison_then_error.py.snap @@ -0,0 +1,124 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_comparison_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..69, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..63, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..63, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..51, + value: Compare( + ExprCompare { + node_index: NodeIndex(None), + range: 46..51, + ops: [ + Lt, + ], + operands: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 50..51, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_comparators: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 52..62, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 56..57, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 61..62, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 65..68, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 65..66, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 68..68, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*x < y for x in z); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_display.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_display.py.snap new file mode 100644 index 0000000000000..98831a11fecf2 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_display.py.snap @@ -0,0 +1,251 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_display.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..114, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..60, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 44..60, + elts: [ + Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..59, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 46..59, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 58..59, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 61..78, + value: Dict( + ExprDict { + node_index: NodeIndex(None), + range: 61..78, + items: [ + DictItem { + key: None, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 64..77, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 69..70, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 64..65, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 76..77, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + }, + ], + runtime_values: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 79..95, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 79..95, + elts: [ + If( + ExprIf { + node_index: NodeIndex(None), + range: 80..94, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 85..86, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 80..81, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 92..94, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 93..94, + id: Name("y"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 96..113, + value: Set( + ExprSet { + node_index: NodeIndex(None), + range: 96..113, + elts: [ + If( + ExprIf { + node_index: NodeIndex(None), + range: 97..112, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 102..103, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 97..98, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 111..112, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ], + runtime_elts: None, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [*x if x else y] + | ^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? +3 | {**x if x else y} +4 | [x if x else *y] + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [*x if x else y] +3 | {**x if x else y} + | ^^^^^^^^^^^^^^^ Syntax Error: invalid double starred expression. Did you forget to wrap the conditional expression in parentheses? +4 | [x if x else *y] +5 | {x if x else **y} + | + + + | +2 | [*x if x else y] +3 | {**x if x else y} +4 | [x if x else *y] + | ^ Syntax Error: cannot unpack only part of a conditional expression +5 | {x if x else **y} + | + + + | +3 | {**x if x else y} +4 | [x if x else *y] +5 | {x if x else **y} + | ^^ Syntax Error: cannot use dict unpacking on only part of a conditional expression diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_then_error.py.snap new file mode 100644 index 0000000000000..7430b5f7a92b2 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_conditional_then_error.py.snap @@ -0,0 +1,132 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_conditional_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..77, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..71, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..71, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..59, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 46..59, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 46..47, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 58..59, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 60..70, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 64..65, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 69..70, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 73..76, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 73..74, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 76..76, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*x if c else y for x in z); a = + | ^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*x if c else y for x in z); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_dict_positions.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_dict_positions.py.snap new file mode 100644 index 0000000000000..fe24b94791923 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_dict_positions.py.snap @@ -0,0 +1,448 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_dict_positions.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..181, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..61, + value: ListComp( + ExprListComp { + node_index: NodeIndex(None), + range: 44..61, + elt: Name( + ExprName { + node_index: NodeIndex(None), + range: 47..48, + id: Name("x"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 49..60, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 53..54, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 58..60, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 62..79, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 62..79, + elt: Name( + ExprName { + node_index: NodeIndex(None), + range: 65..66, + id: Name("x"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 67..78, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 71..72, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 76..78, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 80..98, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 80..98, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 80..81, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 81..98, + node_index: NodeIndex(None), + args: [], + keywords: [ + Keyword { + range: 82..97, + node_index: NodeIndex(None), + arg: None, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 84..85, + id: Name("x"), + ctx: Load, + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 99..118, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 99..118, + key: Some( + Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 100..102, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 101..102, + id: Name("x"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 104..105, + id: Name("y"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 106..117, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 110..111, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 115..117, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 119..139, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 119..139, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 122..123, + id: Name("x"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 125..126, + id: Name("y"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 127..138, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 131..132, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 136..138, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 140..159, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 140..159, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 141..142, + id: Name("x"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 145..146, + id: Name("y"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 147..158, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 151..152, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 156..158, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 160..180, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 160..180, + key: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 161..162, + id: Name("x"), + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 166..167, + id: Name("y"), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 168..179, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 172..173, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 177..179, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**x for x in xs] + | ^^^ Syntax Error: cannot use dict unpacking in list comprehension +3 | (**x for x in xs) +4 | f(**x for x in xs) + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**x for x in xs] +3 | (**x for x in xs) + | ^^^ Syntax Error: cannot use dict unpacking in generator expression +4 | f(**x for x in xs) +5 | {*x: y for x in xs} + | + + + | +2 | [**x for x in xs] +3 | (**x for x in xs) +4 | f(**x for x in xs) + | ^^^ Syntax Error: cannot use dict unpacking in generator expression +5 | {*x: y for x in xs} +6 | {**x: y for x in xs} + | + + + | +3 | (**x for x in xs) +4 | f(**x for x in xs) +5 | {*x: y for x in xs} + | ^^ Syntax Error: cannot use a starred expression in a dictionary key +6 | {**x: y for x in xs} +7 | {x: *y for x in xs} + | + + + | +4 | f(**x for x in xs) +5 | {*x: y for x in xs} +6 | {**x: y for x in xs} + | ^^^ Syntax Error: cannot use dict unpacking in a dictionary key +7 | {x: *y for x in xs} +8 | {x: **y for x in xs} + | + + + | +5 | {*x: y for x in xs} +6 | {**x: y for x in xs} +7 | {x: *y for x in xs} + | ^^ Syntax Error: cannot use a starred expression in a dictionary value +8 | {x: **y for x in xs} + | + + + | +6 | {**x: y for x in xs} +7 | {x: *y for x in xs} +8 | {x: **y for x in xs} + | ^^^ Syntax Error: cannot use dict unpacking in a dictionary value diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_lambda_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_lambda_then_error.py.snap new file mode 100644 index 0000000000000..1d544e50fc8c0 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_lambda_then_error.py.snap @@ -0,0 +1,111 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_lambda_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..73, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..67, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..67, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..55, + value: Lambda( + ExprLambda { + node_index: NodeIndex(None), + range: 46..55, + parameters: None, + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 54..55, + id: Name("x"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 56..66, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 60..61, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 65..66, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 69..72, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 69..70, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 72..72, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*lambda: x for x in z); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_missing_operand.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_missing_operand.py.snap new file mode 100644 index 0000000000000..87a0c16603090 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_missing_operand.py.snap @@ -0,0 +1,216 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_missing_operand.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..78, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 44..48, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 47..47, + id: Name(""), + ctx: Invalid, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 49..53, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..52, + id: Name(""), + ctx: Invalid, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 54..61, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 54..61, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 57..57, + id: Name(""), + ctx: Invalid, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 59..60, + id: Name("x"), + ctx: Load, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 62..77, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 62..77, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 66..69, + id: Name("for"), + ctx: Load, + }, + ), + Compare( + ExprCompare { + node_index: NodeIndex(None), + range: 70..76, + ops: [ + In, + ], + operands: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 70..71, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 75..76, + id: Name("z"), + ctx: Load, + }, + ), + ], + runtime_comparators: None, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**] + | ^^ Syntax Error: invalid syntax +3 | (**) +4 | [**, x] + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**] + | ^ Syntax Error: invalid syntax +3 | (**) +4 | [**, x] + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**] +3 | (**) + | ^^ Syntax Error: invalid syntax +4 | [**, x] +5 | [** for x in z] + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**] +3 | (**) + | ^ Syntax Error: invalid syntax +4 | [**, x] +5 | [** for x in z] + | + + + | +2 | [**] +3 | (**) +4 | [**, x] + | ^^ Syntax Error: invalid syntax +5 | [** for x in z] + | + + + | +2 | [**] +3 | (**) +4 | [**, x] + | ^ Syntax Error: invalid syntax +5 | [** for x in z] + | + + + | +3 | (**) +4 | [**, x] +5 | [** for x in z] + | ^^ Syntax Error: invalid syntax + + + | +3 | (**) +4 | [**, x] +5 | [** for x in z] + | ^^^ Syntax Error: invalid syntax + + + | +3 | (**) +4 | [**, x] +5 | [** for x in z] + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_conditional_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_conditional_then_error.py.snap new file mode 100644 index 0000000000000..1d24aa2cad436 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_conditional_then_error.py.snap @@ -0,0 +1,194 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_conditional_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..105, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..99, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..99, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..86, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 46..86, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 78..79, + id: Name("d"), + ctx: Load, + }, + ), + body: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 46..74, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 47..61, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 48..61, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 53..54, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 48..49, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 60..61, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 62..73, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 66..67, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 71..73, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 85..86, + id: Name("z"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 87..98, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 91..92, + id: Name("z"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 96..98, + id: Name("zs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 101..104, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 101..102, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 104..104, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*(*x if c else y for x in xs) if d else z for z in zs); a = + | ^^^^^^^^^^^^^^ Syntax Error: invalid starred expression. Did you forget to wrap the conditional expression in parentheses? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*(*x if c else y for x in xs) if d else z for z in zs); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_operand.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_operand.py.snap new file mode 100644 index 0000000000000..c629d4a60a159 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_operand.py.snap @@ -0,0 +1,176 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_operand.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..109, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..75, + value: ListComp( + ExprListComp { + node_index: NodeIndex(None), + range: 44..75, + elt: If( + ExprIf { + node_index: NodeIndex(None), + range: 48..62, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 53..54, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 48..49, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 60..62, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 61..62, + id: Name("y"), + ctx: Load, + }, + ), + ctx: Load, + }, + ), + }, + ), + generators: [ + Comprehension { + range: 64..74, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 68..69, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 73..74, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 76..108, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 76..108, + elt: If( + ExprIf { + node_index: NodeIndex(None), + range: 80..95, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 85..86, + id: Name("x"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 80..81, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 94..95, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + generators: [ + Comprehension { + range: 97..107, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 101..102, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 106..107, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**(x if x else *y) for x in z] + | ^ Syntax Error: cannot unpack only part of a conditional expression +3 | (**(x if x else **y) for x in z) + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**(x if x else *y) for x in z] +3 | (**(x if x else **y) for x in z) + | ^^ Syntax Error: cannot use dict unpacking on only part of a conditional expression diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_then_error.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_then_error.py.snap new file mode 100644 index 0000000000000..7f9f10ea14482 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_nested_then_error.py.snap @@ -0,0 +1,162 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_nested_then_error.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..85, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..79, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 44..79, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 45..66, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 46..66, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 47..54, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 48..54, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 48..49, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 53..54, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 55..65, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 59..60, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 64..65, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 67..78, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 71..72, + id: Name("z"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 76..78, + id: Name("zs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 81..84, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 81..82, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 84..84, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | (*(*x or y for x in z) for z in zs); a = + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_soft_keyword_operand.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_soft_keyword_operand.py.snap new file mode 100644 index 0000000000000..c73c34b324de0 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@expressions__pep798_soft_keyword_operand.py.snap @@ -0,0 +1,114 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/expressions/pep798_soft_keyword_operand.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..72, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..53, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 44..53, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 47..52, + id: Name("match"), + ctx: Load, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 54..62, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 54..62, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 57..61, + id: Name("case"), + ctx: Load, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 63..71, + value: List( + ExprList { + node_index: NodeIndex(None), + range: 63..71, + elts: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 66..70, + id: Name("type"), + ctx: Load, + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**match] + | ^^ Syntax Error: cannot use dict unpacking here +3 | [**case] +4 | [**type] + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | [**match] +3 | [**case] + | ^^ Syntax Error: cannot use dict unpacking here +4 | [**type] + | + + + | +2 | [**match] +3 | [**case] +4 | [**type] + | ^^ Syntax Error: cannot use dict unpacking here 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@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/valid_syntax@expressions__pep798_unpacking_operands.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__pep798_unpacking_operands.py.snap new file mode 100644 index 0000000000000..b0e9dcda14ae5 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@expressions__pep798_unpacking_operands.py.snap @@ -0,0 +1,1079 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/valid/expressions/pep798_unpacking_operands.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..464, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..73, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 44..73, + key: None, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 47..60, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..53, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 47..48, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 59..60, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + generators: [ + Comprehension { + range: 61..72, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 65..66, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 70..72, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 74..96, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 74..96, + key: None, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 77..83, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 77..78, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 82..83, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + generators: [ + Comprehension { + range: 84..95, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 88..89, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 93..95, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 97..118, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 97..118, + key: None, + value: Compare( + ExprCompare { + node_index: NodeIndex(None), + range: 100..105, + ops: [ + Lt, + ], + operands: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 100..101, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 104..105, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_comparators: None, + }, + ), + generators: [ + Comprehension { + range: 106..117, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 110..111, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 115..117, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 119..144, + value: DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 119..144, + key: None, + value: Lambda( + ExprLambda { + node_index: NodeIndex(None), + range: 122..131, + parameters: None, + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 130..131, + id: Name("x"), + ctx: Load, + }, + ), + }, + ), + generators: [ + Comprehension { + range: 132..143, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 136..137, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 141..143, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 145..173, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 145..173, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 146..160, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 147..160, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 152..153, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 147..148, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 159..160, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 161..172, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 165..166, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 170..172, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 174..195, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 174..195, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 175..182, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 176..182, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 176..177, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 181..182, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 183..194, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 187..188, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 192..194, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 196..216, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 196..216, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 197..203, + value: Compare( + ExprCompare { + node_index: NodeIndex(None), + range: 198..203, + ops: [ + Lt, + ], + operands: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 198..199, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 202..203, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_comparators: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 204..215, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 208..209, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 213..215, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 217..241, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 217..241, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 218..228, + value: Lambda( + ExprLambda { + node_index: NodeIndex(None), + range: 219..228, + parameters: None, + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 227..228, + id: Name("x"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 229..240, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 233..234, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 238..240, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 242..271, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 242..271, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 242..243, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 243..271, + node_index: NodeIndex(None), + args: [ + Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 244..270, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 244..258, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 245..258, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 250..251, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 245..246, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 257..258, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 259..270, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 263..264, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 268..270, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: false, + }, + ), + ], + keywords: [], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 272..289, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 272..289, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 272..273, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 273..289, + node_index: NodeIndex(None), + args: [ + Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 274..288, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 275..288, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 280..281, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 275..276, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 287..288, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + ], + keywords: [], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 290..325, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 290..325, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 291..312, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 292..312, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 293..300, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 294..300, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 294..295, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 299..300, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 301..311, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 305..306, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 310..311, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 313..324, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 317..318, + id: Name("z"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 322..324, + id: Name("zs"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 326..394, + is_async: true, + decorator_list: [], + name: Identifier { + id: Name("unpack"), + range: 336..342, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 342..346, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 343..345, + node_index: NodeIndex(None), + parameter: Parameter { + range: 343..345, + node_index: NodeIndex(None), + name: Identifier { + id: Name("xs"), + range: 343..345, + node_index: NodeIndex(None), + }, + annotation: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + runtime_defaults: None, + }, + returns: None, + body: [ + Return( + StmtReturn { + node_index: NodeIndex(None), + range: 352..394, + value: Some( + DictComp( + ExprDictComp { + node_index: NodeIndex(None), + range: 359..394, + key: None, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 362..375, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 367..368, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 362..363, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 374..375, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + generators: [ + Comprehension { + range: 376..393, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 386..387, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 391..393, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + }, + ), + ), + }, + ), + ], + runtime_decorator_list: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + runtime_body: None, + }, + ), + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 395..463, + is_async: true, + decorator_list: [], + name: Identifier { + id: Name("iterate"), + range: 405..412, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 412..416, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 413..415, + node_index: NodeIndex(None), + parameter: Parameter { + range: 413..415, + node_index: NodeIndex(None), + name: Identifier { + id: Name("xs"), + range: 413..415, + node_index: NodeIndex(None), + }, + annotation: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + runtime_defaults: None, + }, + returns: None, + body: [ + Return( + StmtReturn { + node_index: NodeIndex(None), + range: 422..463, + value: Some( + Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 429..463, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 430..444, + value: If( + ExprIf { + node_index: NodeIndex(None), + range: 431..444, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 436..437, + id: Name("c"), + ctx: Load, + }, + ), + body: Name( + ExprName { + node_index: NodeIndex(None), + range: 431..432, + id: Name("x"), + ctx: Load, + }, + ), + orelse: Name( + ExprName { + node_index: NodeIndex(None), + range: 443..444, + id: Name("y"), + ctx: Load, + }, + ), + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 445..462, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 455..456, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 460..462, + id: Name("xs"), + ctx: Load, + }, + ), + ifs: [], + is_async: true, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + ), + }, + ), + ], + runtime_decorator_list: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + runtime_body: None, + }, + ), + ], + runtime_body: None, + }, +) +``` From 47a8b0df317597e35c4aebbe618898999de16b61 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Thu, 22 Oct 2026 02:41:43 +0900 Subject: [PATCH 2/5] Use Python 3.15 parameter diagnostic wording Update parameter terminology and repeated-star wording without changing parameter parsing or recovered syntax. Regenerate affected snapshots. Assisted-by: OpenAI Codex:model-version-unavailable --- crates/ruff_python_parser/src/error.rs | 8 ++++---- crates/ruff_python_parser/src/parser/statement.rs | 8 +++----- ...ax@expressions__lambda_duplicate_parameters.py.snap | 2 +- ...syntax@params_expected_after_star_separator.py.snap | 10 +++++----- ...lid_syntax@params_follows_var_keyword_param.py.snap | 10 +++++----- ...id_syntax@params_kwarg_after_star_separator.py.snap | 2 +- .../invalid_syntax@params_multiple_kwargs.py.snap | 2 +- ...valid_syntax@params_multiple_star_separator.py.snap | 2 +- .../invalid_syntax@params_multiple_varargs.py.snap | 6 +++--- .../invalid_syntax@params_no_arg_before_slash.py.snap | 4 ++-- ...ntax@params_star_separator_after_star_param.py.snap | 2 +- ...alid_syntax@params_var_keyword_with_default.py.snap | 2 +- ...d_syntax@params_var_positional_with_default.py.snap | 2 +- 13 files changed, 29 insertions(+), 31 deletions(-) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 9da76b5d6eb2c..77f280de893be 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -609,22 +609,22 @@ 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") diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 730f6d5dba306..8004315a2c0bd 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -3690,9 +3690,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, ); } @@ -3712,7 +3710,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" }; @@ -3798,7 +3796,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/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@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 | From 0371327aea7c7fe4b59c77e1b32f7da061c62469 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Thu, 22 Oct 2026 02:42:09 +0900 Subject: [PATCH 3/5] Add focused Python 3.15 statement diagnostics Diagnose standalone case clauses, assertion assignments, mapping rest ordering and misplaced lazy imports. Preserve ordinary recovered ASTs and restore speculative recovery state, including lexical-error paths. Assisted-by: OpenAI Codex:model-version-unavailable --- .../assert_assignment.py | 2 + .../assert_escape_rhs.py | 2 + .../assert_message_assignment.py | 2 + .../assert_named_expression.py | 2 + .../assert_unclosed_rhs.py | 2 + .../mapping_rest_order.py | 4 + .../python315_diagnostics/misplaced_lazy.py | 2 + .../python315_diagnostics/standalone_case.py | 3 + .../standalone_case_inner_raise.py | 3 + .../standalone_case_mapping_key.py | 3 + .../standalone_case_missing_body.py | 2 + .../standalone_case_unpacking.py | 3 + .../mapping_underscore_key.py | 4 + .../parenthesized_assert.py | 3 + .../soft_keyword_calls.py | 7 + crates/ruff_python_parser/src/error.rs | 44 +++- .../src/parser/case_diagnostics.rs | 155 ++++++++++++ crates/ruff_python_parser/src/parser/mod.rs | 20 +- .../ruff_python_parser/src/parser/pattern.rs | 19 +- .../src/parser/statement.rs | 120 +++++++++- ...lid_syntax@assert_invalid_msg_expr.py.snap | 7 + ...id_syntax@assert_invalid_test_expr.py.snap | 7 + ...315_diagnostics__assert_assignment.py.snap | 59 +++++ ...315_diagnostics__assert_escape_rhs.py.snap | 102 ++++++++ ...nostics__assert_message_assignment.py.snap | 67 ++++++ ...agnostics__assert_named_expression.py.snap | 59 +++++ ...5_diagnostics__assert_unclosed_rhs.py.snap | 65 +++++ ...15_diagnostics__mapping_rest_order.py.snap | 98 ++++++++ ...hon315_diagnostics__misplaced_lazy.py.snap | 76 ++++++ ...on315_diagnostics__standalone_case.py.snap | 94 ++++++++ ...stics__standalone_case_inner_raise.py.snap | 109 +++++++++ ...stics__standalone_case_mapping_key.py.snap | 126 ++++++++++ ...tics__standalone_case_missing_body.py.snap | 75 ++++++ ...nostics__standalone_case_unpacking.py.snap | 186 +++++++++++++++ ...ts__match__invalid_mapping_pattern.py.snap | 6 +- ...iagnostics__mapping_underscore_key.py.snap | 94 ++++++++ ..._diagnostics__parenthesized_assert.py.snap | 85 +++++++ ...15_diagnostics__soft_keyword_calls.py.snap | 223 ++++++++++++++++++ 38 files changed, 1910 insertions(+), 30 deletions(-) create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_assignment.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_escape_rhs.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_message_assignment.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_named_expression.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_unclosed_rhs.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/mapping_rest_order.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/misplaced_lazy.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_inner_raise.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_mapping_key.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_missing_body.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_unpacking.py create mode 100644 crates/ruff_python_parser/resources/valid/python315_diagnostics/mapping_underscore_key.py create mode 100644 crates/ruff_python_parser/resources/valid/python315_diagnostics/parenthesized_assert.py create mode 100644 crates/ruff_python_parser/resources/valid/python315_diagnostics/soft_keyword_calls.py create mode 100644 crates/ruff_python_parser/src/parser/case_diagnostics.rs create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_assignment.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_escape_rhs.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_message_assignment.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_named_expression.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_unclosed_rhs.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__mapping_rest_order.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__misplaced_lazy.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_inner_raise.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_mapping_key.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_missing_body.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_unpacking.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__mapping_underscore_key.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__parenthesized_assert.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__soft_keyword_calls.py.snap diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_assignment.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_assignment.py new file mode 100644 index 0000000000000..629d62d8ae98b --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_assignment.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +assert x = 1 diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_escape_rhs.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_escape_rhs.py new file mode 100644 index 0000000000000..2fc120ba0fbe6 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_escape_rhs.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +assert x = f'{1}\xZZ' diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_message_assignment.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_message_assignment.py new file mode 100644 index 0000000000000..04b8e330869fa --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_message_assignment.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +assert False, x = 1 diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_named_expression.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_named_expression.py new file mode 100644 index 0000000000000..0c40eb3d487a5 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_named_expression.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +assert x := 1 diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_unclosed_rhs.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_unclosed_rhs.py new file mode 100644 index 0000000000000..e2f4fc59242a5 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_unclosed_rhs.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +assert x = (1 diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/mapping_rest_order.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/mapping_rest_order.py new file mode 100644 index 0000000000000..02d3e54d847da --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/mapping_rest_order.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +match x: + case {**rest, 1: value}: + pass diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/misplaced_lazy.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/misplaced_lazy.py new file mode 100644 index 0000000000000..fc7d92653cc0f --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/misplaced_lazy.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +from os.path lazy import join diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case.py new file mode 100644 index 0000000000000..ca39699c54371 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +case 1: + pass diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_inner_raise.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_inner_raise.py new file mode 100644 index 0000000000000..c14c0478f9c0b --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_inner_raise.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +case 1: + raise from None diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_mapping_key.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_mapping_key.py new file mode 100644 index 0000000000000..2d679eccc6ea7 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_mapping_key.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +case {_.x: value}: + pass diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_missing_body.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_missing_body.py new file mode 100644 index 0000000000000..c057b13a08e2f --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_missing_body.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.15"} +case 1: diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_unpacking.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_unpacking.py new file mode 100644 index 0000000000000..f976286f84e61 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_unpacking.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +case 1: + (*x or y for x in z); a = diff --git a/crates/ruff_python_parser/resources/valid/python315_diagnostics/mapping_underscore_key.py b/crates/ruff_python_parser/resources/valid/python315_diagnostics/mapping_underscore_key.py new file mode 100644 index 0000000000000..3598030924ff2 --- /dev/null +++ b/crates/ruff_python_parser/resources/valid/python315_diagnostics/mapping_underscore_key.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +match x: + case {_.x: value}: + pass diff --git a/crates/ruff_python_parser/resources/valid/python315_diagnostics/parenthesized_assert.py b/crates/ruff_python_parser/resources/valid/python315_diagnostics/parenthesized_assert.py new file mode 100644 index 0000000000000..c0a384d1aa0df --- /dev/null +++ b/crates/ruff_python_parser/resources/valid/python315_diagnostics/parenthesized_assert.py @@ -0,0 +1,3 @@ +# parse_options: {"target-version": "3.15"} +assert (x := 1) +assert True, (message := 2) diff --git a/crates/ruff_python_parser/resources/valid/python315_diagnostics/soft_keyword_calls.py b/crates/ruff_python_parser/resources/valid/python315_diagnostics/soft_keyword_calls.py new file mode 100644 index 0000000000000..17841ef1cd194 --- /dev/null +++ b/crates/ruff_python_parser/resources/valid/python315_diagnostics/soft_keyword_calls.py @@ -0,0 +1,7 @@ +# parse_options: {"target-version": "3.15"} +def case(x): + return x +def match(x): + return x +case(1) +match(1) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 77f280de893be..70182c8b5fbcc 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -198,6 +198,15 @@ pub enum ParseErrorType { /// 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. @@ -240,6 +249,8 @@ pub enum ParseErrorType { 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, @@ -259,6 +270,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. @@ -462,6 +477,8 @@ pub enum BlockClause { Finally, Match, Case, + /// A standalone case uses the generic invalid-block diagnostic. + StandaloneCase, Class, FunctionDef, } @@ -480,7 +497,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", }) @@ -505,6 +522,10 @@ 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::ExpectedIndentedBlock { + clause: BlockClause::StandaloneCase, + .. + } => f.write_str("expected an indented block"), ParseErrorType::ExpectedIndentedBlock { clause, line } => { write!( f, @@ -600,6 +621,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 @@ -630,6 +663,9 @@ impl std::fmt::Display for ParseErrorType { 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") } @@ -651,6 +687,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 '='?") } 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/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index ce2c4371c25d6..a2c65974ae60e 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; @@ -1167,22 +1168,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; 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 8004315a2c0bd..bc21572e2d12a 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. @@ -569,9 +579,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 @@ -604,12 +612,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 @@ -787,6 +790,12 @@ impl<'src> Parser<'src> { None }; + if module.is_some() + && let Some(range) = self.misplaced_lazy_import_range() + { + self.add_error(ParseErrorType::MisplacedLazyImport, range); + } + // test_ok from_import_no_space // from.import x // from...import x @@ -865,6 +874,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: @@ -1045,7 +1087,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() { @@ -1054,7 +1100,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, @@ -1076,6 +1127,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 @@ -3363,7 +3459,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. 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@python315_diagnostics__assert_assignment.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_assignment.py.snap new file mode 100644 index 0000000000000..7ae47357f073b --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_assignment.py.snap @@ -0,0 +1,59 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_assignment.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..57, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..52, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("x"), + ctx: Load, + }, + ), + msg: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 55..56, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 55..56, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = 1 + | ^^^^^ Syntax Error: cannot assign to name here. Maybe you meant '==' instead of '='? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = 1 + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_escape_rhs.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_escape_rhs.py.snap new file mode 100644 index 0000000000000..72e2a96254f8d --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_escape_rhs.py.snap @@ -0,0 +1,102 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_escape_rhs.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..66, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..52, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("x"), + ctx: Load, + }, + ), + msg: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 55..65, + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 55..65, + value: FStringValue { + inner: Single( + FString { + range: 55..65, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 57..60, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 58..59, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, + }, + ), + Literal( + InterpolatedStringLiteralElement { + range: 60..64, + node_index: NodeIndex(None), + value: "", + }, + ), + ], + flags: FStringFlags { + quote_style: Single, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ), + }, + runtime_joined_str: None, + runtime_values: None, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = f'{1}\xZZ' + | ^ Syntax Error: invalid syntax + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = f'{1}\xZZ' + | ^ Syntax Error: (unicode error) 'unicodeescape' codec can't decode bytes in position 0-1: truncated \xXX escape diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_message_assignment.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_message_assignment.py.snap new file mode 100644 index 0000000000000..a4a8fa34cc94b --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_message_assignment.py.snap @@ -0,0 +1,67 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_message_assignment.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..64, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..59, + test: BooleanLiteral( + ExprBooleanLiteral { + node_index: NodeIndex(None), + range: 51..56, + value: false, + }, + ), + msg: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 58..59, + id: Name("x"), + ctx: Load, + }, + ), + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 62..63, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 62..63, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert False, x = 1 + | ^^^^^ Syntax Error: cannot assign to name here. Maybe you meant '==' instead of '='? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert False, x = 1 + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_named_expression.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_named_expression.py.snap new file mode 100644 index 0000000000000..54b9122f6a203 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_named_expression.py.snap @@ -0,0 +1,59 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_named_expression.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..58, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..52, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("x"), + ctx: Load, + }, + ), + msg: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 56..57, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 56..57, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x := 1 + | ^^^^^^ Syntax Error: cannot use named expression without parentheses here + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x := 1 + | ^^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_unclosed_rhs.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_unclosed_rhs.py.snap new file mode 100644 index 0000000000000..b5ab4ce7489d7 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__assert_unclosed_rhs.py.snap @@ -0,0 +1,65 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/assert_unclosed_rhs.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..58, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..52, + test: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..52, + id: Name("x"), + ctx: Load, + }, + ), + msg: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 55..57, + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 56..57, + value: Int( + 1, + ), + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = (1 + | ^ Syntax Error: invalid syntax + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = (1 + | ^ Syntax Error: '(' was never closed + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | assert x = (1 + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__mapping_rest_order.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__mapping_rest_order.py.snap new file mode 100644 index 0000000000000..5c03f15939ef2 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__mapping_rest_order.py.snap @@ -0,0 +1,98 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/mapping_rest_order.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..95, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 44..94, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..51, + id: Name("x"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 57..94, + node_index: NodeIndex(None), + pattern: MatchMapping( + PatternMatchMapping { + node_index: NodeIndex(None), + range: 62..80, + keys: [ + NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 71..72, + value: Int( + 1, + ), + }, + ), + ], + patterns: [ + MatchAs( + PatternMatchAs { + node_index: NodeIndex(None), + range: 74..79, + pattern: None, + name: Some( + Identifier { + id: Name("value"), + range: 74..79, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + rest: Some( + Identifier { + id: Name("rest"), + range: 65..69, + node_index: NodeIndex(None), + }, + ), + runtime_keys: None, + runtime_patterns: None, + }, + ), + guard: None, + body: [ + Pass( + StmtPass { + node_index: NodeIndex(None), + range: 90..94, + }, + ), + ], + runtime_body: None, + }, + ], + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | match x: +3 | case {**rest, 1: value}: + | ^^^^ Syntax Error: double star pattern must be the last (right-most) subpattern in the mapping pattern +4 | pass + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__misplaced_lazy.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__misplaced_lazy.py.snap new file mode 100644 index 0000000000000..ca1ba425c08ef --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__misplaced_lazy.py.snap @@ -0,0 +1,76 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/misplaced_lazy.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..74, + body: [ + ImportFrom( + StmtImportFrom { + node_index: NodeIndex(None), + range: 44..61, + module: Some( + Identifier { + id: Name("os.path"), + range: 49..56, + node_index: NodeIndex(None), + }, + ), + names: [ + Alias { + range: 57..61, + node_index: NodeIndex(None), + name: Identifier { + id: Name("lazy"), + range: 57..61, + node_index: NodeIndex(None), + }, + asname: None, + }, + ], + level: 0, + is_lazy: false, + runtime_level: None, + }, + ), + Import( + StmtImport { + node_index: NodeIndex(None), + range: 62..73, + names: [ + Alias { + range: 69..73, + node_index: NodeIndex(None), + name: Identifier { + id: Name("join"), + range: 69..73, + node_index: NodeIndex(None), + }, + asname: None, + }, + ], + is_lazy: false, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | from os.path lazy import join + | ^^^^ Syntax Error: use 'lazy from ... ' instead of 'from ... lazy import' + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | from os.path lazy import join + | ^^^^^^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case.py.snap new file mode 100644 index 0000000000000..6d3f779f68ae7 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case.py.snap @@ -0,0 +1,94 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..61, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..48, + id: Name("case"), + ctx: Load, + }, + ), + }, + ), + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 49..51, + target: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 49..50, + value: Int( + 1, + ), + }, + ), + annotation: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..51, + id: Name(""), + ctx: Invalid, + }, + ), + value: None, + simple: false, + runtime_simple: None, + }, + ), + Pass( + StmtPass { + node_index: NodeIndex(None), + range: 56..60, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^^^^^^^ Syntax Error: case statement must be inside match statement +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | pass + | ^ Syntax Error: unexpected indent diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_inner_raise.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_inner_raise.py.snap new file mode 100644 index 0000000000000..b9c65e782d250 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_inner_raise.py.snap @@ -0,0 +1,109 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_inner_raise.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..72, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..48, + id: Name("case"), + ctx: Load, + }, + ), + }, + ), + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 49..51, + target: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 49..50, + value: Int( + 1, + ), + }, + ), + annotation: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..51, + id: Name(""), + ctx: Invalid, + }, + ), + value: None, + simple: false, + runtime_simple: None, + }, + ), + Raise( + StmtRaise { + node_index: NodeIndex(None), + range: 56..71, + exc: None, + cause: Some( + NoneLiteral( + ExprNoneLiteral { + node_index: NodeIndex(None), + range: 67..71, + }, + ), + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | raise from None + | ^^^^^^^^^^ Syntax Error: did you forget an expression between 'raise' and 'from'? + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | raise from None + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | raise from None + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | raise from None + | ^ Syntax Error: unexpected indent + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | raise from None + | ^^^^^^^^^^ Syntax Error: did you forget an expression between 'raise' and 'from'? diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_mapping_key.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_mapping_key.py.snap new file mode 100644 index 0000000000000..df51722a3b2bb --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_mapping_key.py.snap @@ -0,0 +1,126 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_mapping_key.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..72, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..48, + id: Name("case"), + ctx: Load, + }, + ), + }, + ), + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 49..62, + target: Dict( + ExprDict { + node_index: NodeIndex(None), + range: 49..61, + items: [ + DictItem { + key: Some( + Attribute( + ExprAttribute { + node_index: NodeIndex(None), + range: 50..53, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..51, + id: Name("_"), + ctx: Load, + }, + ), + attr: Identifier { + id: Name("x"), + range: 52..53, + node_index: NodeIndex(None), + }, + ctx: Load, + }, + ), + ), + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 55..60, + id: Name("value"), + ctx: Load, + }, + ), + }, + ], + runtime_values: None, + }, + ), + annotation: Name( + ExprName { + node_index: NodeIndex(None), + range: 62..62, + id: Name(""), + ctx: Invalid, + }, + ), + value: None, + simple: false, + runtime_simple: None, + }, + ), + Pass( + StmtPass { + node_index: NodeIndex(None), + range: 67..71, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case {_.x: value}: + | ^^^^^^^^^^^^^^^^^^ Syntax Error: case statement must be inside match statement +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case {_.x: value}: + | ^ Syntax Error: invalid syntax +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case {_.x: value}: + | ^ Syntax Error: invalid syntax +3 | pass + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case {_.x: value}: +3 | pass + | ^ Syntax Error: unexpected indent diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_missing_body.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_missing_body.py.snap new file mode 100644 index 0000000000000..46c47269cb9b6 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_missing_body.py.snap @@ -0,0 +1,75 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_missing_body.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..52, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..48, + id: Name("case"), + ctx: Load, + }, + ), + }, + ), + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 49..51, + target: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 49..50, + value: Int( + 1, + ), + }, + ), + annotation: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..51, + id: Name(""), + ctx: Invalid, + }, + ), + value: None, + simple: false, + runtime_simple: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: expected an indented block + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_unpacking.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_unpacking.py.snap new file mode 100644 index 0000000000000..834c3bd9db03c --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__standalone_case_unpacking.py.snap @@ -0,0 +1,186 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/standalone_case_unpacking.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..82, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..48, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..48, + id: Name("case"), + ctx: Load, + }, + ), + }, + ), + AnnAssign( + StmtAnnAssign { + node_index: NodeIndex(None), + range: 49..51, + target: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 49..50, + value: Int( + 1, + ), + }, + ), + annotation: Name( + ExprName { + node_index: NodeIndex(None), + range: 51..51, + id: Name(""), + ctx: Invalid, + }, + ), + value: None, + simple: false, + runtime_simple: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 56..76, + value: Generator( + ExprGenerator { + node_index: NodeIndex(None), + range: 56..76, + elt: Starred( + ExprStarred { + node_index: NodeIndex(None), + range: 57..64, + value: BoolOp( + ExprBoolOp { + node_index: NodeIndex(None), + range: 58..64, + op: Or, + values: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 58..59, + id: Name("x"), + ctx: Load, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 63..64, + id: Name("y"), + ctx: Load, + }, + ), + ], + runtime_values: None, + }, + ), + ctx: Load, + }, + ), + generators: [ + Comprehension { + range: 65..75, + node_index: NodeIndex(None), + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 69..70, + id: Name("x"), + ctx: Store, + }, + ), + iter: Name( + ExprName { + node_index: NodeIndex(None), + range: 74..75, + id: Name("z"), + ctx: Load, + }, + ), + ifs: [], + is_async: false, + runtime_ifs: None, + runtime_is_async: None, + }, + ], + parenthesized: true, + }, + ), + }, + ), + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 78..81, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 78..79, + id: Name("a"), + ctx: Store, + }, + ), + ], + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 81..81, + id: Name(""), + ctx: Invalid, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | (*x or y for x in z); a = + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: + | ^ Syntax Error: invalid syntax +3 | (*x or y for x in z); a = + | + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | (*x or y for x in z); a = + | ^ Syntax Error: unexpected indent + + + | +1 | # parse_options: {"target-version": "3.15"} +2 | case 1: +3 | (*x or y for x in z); a = + | ^ Syntax Error: invalid syntax 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 | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__mapping_underscore_key.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__mapping_underscore_key.py.snap new file mode 100644 index 0000000000000..952222fe9e2ab --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__mapping_underscore_key.py.snap @@ -0,0 +1,94 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/valid/python315_diagnostics/mapping_underscore_key.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..89, + body: [ + Match( + StmtMatch { + node_index: NodeIndex(None), + range: 44..88, + subject: Name( + ExprName { + node_index: NodeIndex(None), + range: 50..51, + id: Name("x"), + ctx: Load, + }, + ), + cases: [ + MatchCase { + range: 57..88, + node_index: NodeIndex(None), + pattern: MatchMapping( + PatternMatchMapping { + node_index: NodeIndex(None), + range: 62..74, + keys: [ + Attribute( + ExprAttribute { + node_index: NodeIndex(None), + range: 63..66, + value: Name( + ExprName { + node_index: NodeIndex(None), + range: 63..64, + id: Name("_"), + ctx: Load, + }, + ), + attr: Identifier { + id: Name("x"), + range: 65..66, + node_index: NodeIndex(None), + }, + ctx: Load, + }, + ), + ], + patterns: [ + MatchAs( + PatternMatchAs { + node_index: NodeIndex(None), + range: 68..73, + pattern: None, + name: Some( + Identifier { + id: Name("value"), + range: 68..73, + node_index: NodeIndex(None), + }, + ), + }, + ), + ], + rest: None, + runtime_keys: None, + runtime_patterns: None, + }, + ), + guard: None, + body: [ + Pass( + StmtPass { + node_index: NodeIndex(None), + range: 84..88, + }, + ), + ], + runtime_body: None, + }, + ], + }, + ), + ], + runtime_body: None, + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__parenthesized_assert.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__parenthesized_assert.py.snap new file mode 100644 index 0000000000000..0f197613b5366 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__parenthesized_assert.py.snap @@ -0,0 +1,85 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/valid/python315_diagnostics/parenthesized_assert.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..88, + body: [ + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 44..59, + test: Named( + ExprNamed { + node_index: NodeIndex(None), + range: 52..58, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 52..53, + id: Name("x"), + ctx: Store, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 57..58, + value: Int( + 1, + ), + }, + ), + }, + ), + msg: None, + }, + ), + Assert( + StmtAssert { + node_index: NodeIndex(None), + range: 60..87, + test: BooleanLiteral( + ExprBooleanLiteral { + node_index: NodeIndex(None), + range: 67..71, + value: true, + }, + ), + msg: Some( + Named( + ExprNamed { + node_index: NodeIndex(None), + range: 74..86, + target: Name( + ExprName { + node_index: NodeIndex(None), + range: 74..81, + id: Name("message"), + ctx: Store, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 85..86, + value: Int( + 2, + ), + }, + ), + }, + ), + ), + }, + ), + ], + runtime_body: None, + }, +) +``` diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__soft_keyword_calls.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__soft_keyword_calls.py.snap new file mode 100644 index 0000000000000..4db88f9fb86b0 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@python315_diagnostics__soft_keyword_calls.py.snap @@ -0,0 +1,223 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/valid/python315_diagnostics/soft_keyword_calls.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..114, + body: [ + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 44..69, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("case"), + range: 48..52, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 52..55, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 53..54, + node_index: NodeIndex(None), + parameter: Parameter { + range: 53..54, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 53..54, + node_index: NodeIndex(None), + }, + annotation: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + runtime_defaults: None, + }, + returns: None, + body: [ + Return( + StmtReturn { + node_index: NodeIndex(None), + range: 61..69, + value: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 68..69, + id: Name("x"), + ctx: Load, + }, + ), + ), + }, + ), + ], + runtime_decorator_list: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + runtime_body: None, + }, + ), + FunctionDef( + StmtFunctionDef { + node_index: NodeIndex(None), + range: 70..96, + is_async: false, + decorator_list: [], + name: Identifier { + id: Name("match"), + range: 74..79, + node_index: NodeIndex(None), + }, + type_params: None, + parameters: Parameters { + range: 79..82, + node_index: NodeIndex(None), + posonlyargs: [], + args: [ + ParameterWithDefault { + range: 80..81, + node_index: NodeIndex(None), + parameter: Parameter { + range: 80..81, + node_index: NodeIndex(None), + name: Identifier { + id: Name("x"), + range: 80..81, + node_index: NodeIndex(None), + }, + annotation: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + default: None, + }, + ], + vararg: None, + kwonlyargs: [], + kwarg: None, + runtime_defaults: None, + }, + returns: None, + body: [ + Return( + StmtReturn { + node_index: NodeIndex(None), + range: 88..96, + value: Some( + Name( + ExprName { + node_index: NodeIndex(None), + range: 95..96, + id: Name("x"), + ctx: Load, + }, + ), + ), + }, + ), + ], + runtime_decorator_list: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + runtime_body: None, + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 97..104, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 97..104, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 97..101, + id: Name("case"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 101..104, + node_index: NodeIndex(None), + args: [ + NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 102..103, + value: Int( + 1, + ), + }, + ), + ], + keywords: [], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 105..113, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 105..113, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 105..110, + id: Name("match"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 110..113, + node_index: NodeIndex(None), + args: [ + NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 111..112, + value: Int( + 1, + ), + }, + ), + ], + keywords: [], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` From dbeb3f77286dc9e9a1e8dcf1c727c5a92772a5b5 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Thu, 22 Oct 2026 02:42:26 +0900 Subject: [PATCH 4/5] Locate concatenation comma hints on the final literal line Project multiline literal-concatenation diagnostics onto the final literal line while retaining original expression ranges for lexical error precedence. Assisted-by: OpenAI Codex:model-version-unavailable --- .../concat_bad_escape.py | 5 + .../python315_diagnostics/concat_bytes.py | 6 + .../python315_diagnostics/concat_fstring.py | 6 + .../python315_diagnostics/concat_string.py | 7 + .../python315_diagnostics/concat_tstring.py | 6 + .../concat_unclosed_display.py | 5 + .../python315_diagnostics/concat_uneven.py | 6 + .../python315_diagnostics/concat_unicode.py | 6 + crates/ruff_python_parser/src/error.rs | 13 +- .../src/parser/expression.rs | 51 ++++++ crates/ruff_python_parser/src/parser/mod.rs | 7 +- ...315_diagnostics__concat_bad_escape.py.snap | 155 ++++++++++++++++++ ...ython315_diagnostics__concat_bytes.py.snap | 132 +++++++++++++++ ...hon315_diagnostics__concat_fstring.py.snap | 142 ++++++++++++++++ ...thon315_diagnostics__concat_string.py.snap | 134 +++++++++++++++ ...hon315_diagnostics__concat_tstring.py.snap | 138 ++++++++++++++++ ...agnostics__concat_unclosed_display.py.snap | 138 ++++++++++++++++ ...thon315_diagnostics__concat_uneven.py.snap | 123 ++++++++++++++ ...hon315_diagnostics__concat_unicode.py.snap | 123 ++++++++++++++ 19 files changed, 1201 insertions(+), 2 deletions(-) create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bad_escape.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bytes.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_fstring.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_string.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_tstring.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unclosed_display.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_uneven.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unicode.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bad_escape.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bytes.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_fstring.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_string.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_tstring.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unclosed_display.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_uneven.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unicode.py.snap diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bad_escape.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bad_escape.py new file mode 100644 index 0000000000000..8db8265f9c0ea --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bad_escape.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +print( + "first" + "last" + x=1, "\xZZ") diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bytes.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bytes.py new file mode 100644 index 0000000000000..2554ef22577f9 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bytes.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.15"} +print( + b"line1" + b"line2" + x=1 +) diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_fstring.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_fstring.py new file mode 100644 index 0000000000000..e4814e683dbb8 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_fstring.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.15"} +print( + f"line1" + f"line2" + x=1 +) diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_string.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_string.py new file mode 100644 index 0000000000000..cb54eefb392d6 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_string.py @@ -0,0 +1,7 @@ +# parse_options: {"target-version": "3.15"} +print( + "line1" + "line2" + "line3" + x=1 +) diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_tstring.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_tstring.py new file mode 100644 index 0000000000000..47d9b57659269 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_tstring.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.15"} +print( + t"line1" + t"line2" + x=1 +) diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unclosed_display.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unclosed_display.py new file mode 100644 index 0000000000000..12ac8faa8a3cb --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unclosed_display.py @@ -0,0 +1,5 @@ +# parse_options: {"target-version": "3.15"} +values = [ + "first" + "last" + x=1 diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_uneven.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_uneven.py new file mode 100644 index 0000000000000..eeab876c1b714 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_uneven.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.15"} +print( + "first" + "last" + x=1 +) diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unicode.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unicode.py new file mode 100644 index 0000000000000..d2b6ae23985ce --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unicode.py @@ -0,0 +1,6 @@ +# parse_options: {"target-version": "3.15"} +print( + "é" + "한글" + x=1 +) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 70182c8b5fbcc..09616256234e6 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; @@ -195,6 +195,14 @@ 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), @@ -522,6 +530,9 @@ 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, .. diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 062b51566591b..44e7b03af04c7 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. diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index a2c65974ae60e..6c2bc51afa137 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -2096,7 +2096,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)) diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bad_escape.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bad_escape.py.snap new file mode 100644 index 0000000000000..d964a7f86f359 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bad_escape.py.snap @@ -0,0 +1,155 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bad_escape.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..91, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..90, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..90, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..90, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 55..73, + value: StringLiteralValue { + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 55..62, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 67..73, + node_index: NodeIndex(None), + value: "last", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ], + value: "firstlast", + }, + ), + }, + }, + ), + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 83..89, + value: StringLiteralValue { + inner: Single( + StringLiteral { + range: 83..89, + node_index: NodeIndex(None), + value: "", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ), + }, + }, + ), + ], + keywords: [ + Keyword { + range: 78..81, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 78..79, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 80..81, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | "first" +4 | "last" + | ^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1, "\xZZ") + | + + + | +3 | "first" +4 | "last" +5 | x=1, "\xZZ") + | ^ Syntax Error: invalid syntax + + + | +3 | "first" +4 | "last" +5 | x=1, "\xZZ") + | ^^^^^^ Syntax Error: (unicode error) 'unicodeescape' codec can't decode bytes in position 0-1: truncated \xXX escape + + + | +3 | "first" +4 | "last" +5 | x=1, "\xZZ") + | ^ Syntax Error: positional argument follows keyword argument diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bytes.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bytes.py.snap new file mode 100644 index 0000000000000..3f5ba31f70788 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_bytes.py.snap @@ -0,0 +1,132 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_bytes.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..87, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..86, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..86, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..86, + node_index: NodeIndex(None), + args: [ + BytesLiteral( + ExprBytesLiteral { + node_index: NodeIndex(None), + range: 55..76, + value: BytesLiteralValue { + inner: Concatenated( + [ + BytesLiteral { + range: 55..63, + node_index: NodeIndex(None), + value: [ + 108, + 105, + 110, + 101, + 49, + ], + flags: BytesLiteralFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + BytesLiteral { + range: 68..76, + node_index: NodeIndex(None), + value: [ + 108, + 105, + 110, + 101, + 50, + ], + flags: BytesLiteralFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ], + ), + }, + }, + ), + ], + keywords: [ + Keyword { + range: 81..84, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 81..82, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 83..84, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | b"line1" +4 | b"line2" + | ^^^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 +6 | ) + | + + + | +3 | b"line1" +4 | b"line2" +5 | x=1 + | ^ Syntax Error: invalid syntax +6 | ) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_fstring.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_fstring.py.snap new file mode 100644 index 0000000000000..81b0a3e72b02f --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_fstring.py.snap @@ -0,0 +1,142 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_fstring.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..87, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..86, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..86, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..86, + node_index: NodeIndex(None), + args: [ + FString( + ExprFString { + node_index: NodeIndex(None), + range: 55..76, + value: FStringValue { + inner: Concatenated( + [ + FString( + FString { + range: 55..63, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 57..62, + node_index: NodeIndex(None), + value: "line1", + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ), + FString( + FString { + range: 68..76, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 70..75, + node_index: NodeIndex(None), + value: "line2", + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ), + ], + ), + }, + runtime_joined_str: None, + runtime_values: None, + }, + ), + ], + keywords: [ + Keyword { + range: 81..84, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 81..82, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 83..84, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | f"line1" +4 | f"line2" + | ^^^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 +6 | ) + | + + + | +3 | f"line1" +4 | f"line2" +5 | x=1 + | ^ Syntax Error: invalid syntax +6 | ) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_string.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_string.py.snap new file mode 100644 index 0000000000000..c8ea86a31a4f8 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_string.py.snap @@ -0,0 +1,134 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_string.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..97, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..96, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..96, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..96, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 55..86, + value: StringLiteralValue { + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 55..62, + node_index: NodeIndex(None), + value: "line1", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 67..74, + node_index: NodeIndex(None), + value: "line2", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 79..86, + node_index: NodeIndex(None), + value: "line3", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ], + value: "line1line2line3", + }, + ), + }, + }, + ), + ], + keywords: [ + Keyword { + range: 91..94, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 91..92, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 93..94, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +3 | "line1" +4 | "line2" +5 | "line3" + | ^^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +6 | x=1 +7 | ) + | + + + | +4 | "line2" +5 | "line3" +6 | x=1 + | ^ Syntax Error: invalid syntax +7 | ) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_tstring.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_tstring.py.snap new file mode 100644 index 0000000000000..8c63394f8c409 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_tstring.py.snap @@ -0,0 +1,138 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_tstring.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..87, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..86, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..86, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..86, + node_index: NodeIndex(None), + args: [ + TString( + ExprTString { + node_index: NodeIndex(None), + range: 55..76, + value: TStringValue { + inner: Concatenated( + [ + TString { + range: 55..63, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 57..62, + node_index: NodeIndex(None), + value: "line1", + }, + ), + ], + flags: TStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + TString { + range: 68..76, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 70..75, + node_index: NodeIndex(None), + value: "line2", + }, + ), + ], + flags: TStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: false, + unclosed: false, + }, + }, + ], + ), + }, + runtime_template_str: None, + runtime_values: None, + }, + ), + ], + keywords: [ + Keyword { + range: 81..84, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 81..82, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 83..84, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | t"line1" +4 | t"line2" + | ^^^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 +6 | ) + | + + + | +3 | t"line1" +4 | t"line2" +5 | x=1 + | ^ Syntax Error: invalid syntax +6 | ) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unclosed_display.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unclosed_display.py.snap new file mode 100644 index 0000000000000..9cacb9ceafe2a --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unclosed_display.py.snap @@ -0,0 +1,138 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unclosed_display.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..86, + body: [ + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 44..86, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 44..50, + id: Name("values"), + ctx: Store, + }, + ), + ], + value: List( + ExprList { + node_index: NodeIndex(None), + range: 53..86, + elts: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 59..77, + value: StringLiteralValue { + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 59..66, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 71..77, + node_index: NodeIndex(None), + value: "last", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ], + value: "firstlast", + }, + ), + }, + }, + ), + Name( + ExprName { + node_index: NodeIndex(None), + range: 82..83, + id: Name("x"), + ctx: Load, + }, + ), + NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 84..85, + value: Int( + 1, + ), + }, + ), + ], + ctx: Load, + runtime_elts: None, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +1 | # parse_options: {"target-version": "3.15"} +2 | values = [ + | ^ Syntax Error: '[' was never closed +3 | "first" +4 | "last" + | + + + | +2 | values = [ +3 | "first" +4 | "last" + | ^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 + | + + + | +3 | "first" +4 | "last" +5 | x=1 + | ^ Syntax Error: invalid syntax + + + | +3 | "first" +4 | "last" +5 | x=1 + | ^ Syntax Error: invalid syntax + + + | +4 | "last" +5 | x=1 + | ^ Syntax Error: invalid syntax diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_uneven.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_uneven.py.snap new file mode 100644 index 0000000000000..249545f42a3bf --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_uneven.py.snap @@ -0,0 +1,123 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_uneven.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..84, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..83, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..83, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..83, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 61..76, + value: StringLiteralValue { + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 61..68, + node_index: NodeIndex(None), + value: "first", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 70..76, + node_index: NodeIndex(None), + value: "last", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ], + value: "firstlast", + }, + ), + }, + }, + ), + ], + keywords: [ + Keyword { + range: 78..81, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 78..79, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 80..81, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | "first" +4 | "last" + | ^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 +6 | ) + | + + + | +3 | "first" +4 | "last" +5 | x=1 + | ^ Syntax Error: invalid syntax +6 | ) + | diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unicode.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unicode.py.snap new file mode 100644 index 0000000000000..6c746752d718d --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__concat_unicode.py.snap @@ -0,0 +1,123 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/concat_unicode.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..83, + body: [ + Expr( + StmtExpr { + node_index: NodeIndex(None), + range: 44..82, + value: Call( + ExprCall { + node_index: NodeIndex(None), + range: 44..82, + func: Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("print"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 49..82, + node_index: NodeIndex(None), + args: [ + StringLiteral( + ExprStringLiteral { + node_index: NodeIndex(None), + range: 55..72, + value: StringLiteralValue { + inner: Concatenated( + ConcatenatedStringLiteral { + strings: [ + StringLiteral { + range: 55..59, + node_index: NodeIndex(None), + value: "é", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + StringLiteral { + range: 64..72, + node_index: NodeIndex(None), + value: "한글", + flags: StringLiteralFlags { + quote_style: Double, + prefix: Empty, + triple_quoted: false, + unclosed: false, + }, + }, + ], + value: "é한글", + }, + ), + }, + }, + ), + ], + keywords: [ + Keyword { + range: 77..80, + node_index: NodeIndex(None), + arg: Some( + Identifier { + id: Name("x"), + range: 77..78, + node_index: NodeIndex(None), + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 79..80, + value: Int( + 1, + ), + }, + ), + }, + ], + runtime_args: None, + runtime_bases: None, + }, + }, + ), + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | print( +3 | "é" +4 | "한글" + | ^^^^^^ Syntax Error: invalid syntax. Perhaps you forgot a comma? +5 | x=1 +6 | ) + | + + + | +3 | "é" +4 | "한글" +5 | x=1 + | ^ Syntax Error: invalid syntax +6 | ) + | From 90bd525707bd72653ef5f2a32653c164a5c43a98 Mon Sep 17 00:00:00 2001 From: youknowdot Date: Thu, 22 Oct 2026 02:42:50 +0900 Subject: [PATCH 5/5] Identify unclosed multiline replacement fields Report the opening brace line for multiline f-string and t-string replacement fields. Track lexical hint boundaries without changing recovered syntax or existing PEP 798 deferred-error state. Assisted-by: OpenAI Codex:model-version-unavailable --- .../fstring_nested_field.py | 4 + .../fstring_unclosed_field.py | 4 + .../tstring_unclosed_field.py | 4 + crates/ruff_python_parser/src/error.rs | 16 ++- crates/ruff_python_parser/src/lexer.rs | 26 +++- .../src/lexer/interpolated_string.rs | 5 + crates/ruff_python_parser/src/parser/mod.rs | 106 +++++++++++++- ..._diagnostics__fstring_nested_field.py.snap | 130 ++++++++++++++++++ ...iagnostics__fstring_unclosed_field.py.snap | 95 +++++++++++++ ...iagnostics__tstring_unclosed_field.py.snap | 95 +++++++++++++ 10 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_nested_field.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_unclosed_field.py create mode 100644 crates/ruff_python_parser/resources/invalid/python315_diagnostics/tstring_unclosed_field.py create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_nested_field.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_unclosed_field.py.snap create mode 100644 crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__tstring_unclosed_field.py.snap diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_nested_field.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_nested_field.py new file mode 100644 index 0000000000000..76d4ffd7131a0 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_nested_field.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +value = f"""{0: +{1 +""" diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_unclosed_field.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_unclosed_field.py new file mode 100644 index 0000000000000..fd7d459812e64 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_unclosed_field.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +value = f"""{ +1 +""" diff --git a/crates/ruff_python_parser/resources/invalid/python315_diagnostics/tstring_unclosed_field.py b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/tstring_unclosed_field.py new file mode 100644 index 0000000000000..ccc3b807e40f2 --- /dev/null +++ b/crates/ruff_python_parser/resources/invalid/python315_diagnostics/tstring_unclosed_field.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.15"} +value = t"""{ +1 +""" diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 09616256234e6..9a20cc90bda93 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -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"), @@ -928,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 { .. } + ) ) } @@ -951,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 3dea914fcea9d..1674ca6d65460 100644 --- a/crates/ruff_python_parser/src/lexer.rs +++ b/crates/ruff_python_parser/src/lexer.rs @@ -1126,7 +1126,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(), ) } @@ -1162,6 +1162,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/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 6c2bc51afa137..4515a676b2737 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -2058,7 +2058,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(); @@ -2066,21 +2069,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 @@ -2088,6 +2096,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; } }; @@ -2116,7 +2134,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() { @@ -2153,6 +2172,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/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_nested_field.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_nested_field.py.snap new file mode 100644 index 0000000000000..be2c6c59509bf --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_nested_field.py.snap @@ -0,0 +1,130 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_nested_field.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..67, + body: [ + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 44..66, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("value"), + ctx: Store, + }, + ), + ], + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 52..66, + value: FStringValue { + inner: Single( + FString { + range: 52..66, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 56..62, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 57..58, + value: Int( + 0, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: Some( + InterpolatedStringFormatSpec { + range: 59..62, + node_index: NodeIndex(None), + elements: [ + Literal( + InterpolatedStringLiteralElement { + range: 59..60, + node_index: NodeIndex(None), + value: "\n", + }, + ), + Interpolation( + InterpolatedElement { + range: 60..62, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 61..62, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, + }, + ), + ], + }, + ), + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: true, + unclosed: false, + }, + }, + ), + }, + runtime_joined_str: None, + runtime_values: None, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | value = f"""{0: +3 | {1 +4 | """ + | ^ Syntax Error: f-string: expecting '}' to close '{' on line 3 + + + | +2 | value = f"""{0: +3 | {1 +4 | """ + | ^^^ Syntax Error: f-string: expecting '}' diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_unclosed_field.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_unclosed_field.py.snap new file mode 100644 index 0000000000000..b4c5a59c31d72 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__fstring_unclosed_field.py.snap @@ -0,0 +1,95 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/fstring_unclosed_field.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..64, + body: [ + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 44..63, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("value"), + ctx: Store, + }, + ), + ], + value: FString( + ExprFString { + node_index: NodeIndex(None), + range: 52..63, + value: FStringValue { + inner: Single( + FString { + range: 52..63, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 56..59, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 58..59, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, + }, + ), + ], + flags: FStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: true, + unclosed: false, + }, + }, + ), + }, + runtime_joined_str: None, + runtime_values: None, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | value = f"""{ +3 | 1 +4 | """ + | ^ Syntax Error: f-string: expecting '}' to close '{' on line 2 + + + | +2 | value = f"""{ +3 | 1 +4 | """ + | ^^^ Syntax Error: f-string: expecting '}' diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__tstring_unclosed_field.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__tstring_unclosed_field.py.snap new file mode 100644 index 0000000000000..336dc29160c4d --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@python315_diagnostics__tstring_unclosed_field.py.snap @@ -0,0 +1,95 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/invalid/python315_diagnostics/tstring_unclosed_field.py +--- +## AST + +``` +Module( + ModModule { + node_index: NodeIndex(None), + range: 0..64, + body: [ + Assign( + StmtAssign { + node_index: NodeIndex(None), + range: 44..63, + targets: [ + Name( + ExprName { + node_index: NodeIndex(None), + range: 44..49, + id: Name("value"), + ctx: Store, + }, + ), + ], + value: TString( + ExprTString { + node_index: NodeIndex(None), + range: 52..63, + value: TStringValue { + inner: Single( + TString { + range: 52..63, + node_index: NodeIndex(None), + elements: [ + Interpolation( + InterpolatedElement { + range: 56..59, + node_index: NodeIndex(None), + expression: NumberLiteral( + ExprNumberLiteral { + node_index: NodeIndex(None), + range: 58..59, + value: Int( + 1, + ), + }, + ), + debug_text: None, + conversion: None, + format_spec: None, + runtime_str: None, + runtime_interpolation_format_spec: None, + runtime_formatted_value_format_spec: None, + }, + ), + ], + flags: TStringFlags { + quote_style: Double, + prefix: Regular, + triple_quoted: true, + unclosed: false, + }, + }, + ), + }, + runtime_template_str: None, + runtime_values: None, + }, + ), + runtime_targets: None, + runtime_type_comment: None, + runtime_type_comment_bytes: None, + }, + ), + ], + runtime_body: None, + }, +) +``` +## Errors + + | +2 | value = t"""{ +3 | 1 +4 | """ + | ^ Syntax Error: t-string: expecting '}' to close '{' on line 2 + + + | +2 | value = t"""{ +3 | 1 +4 | """ + | ^^^ Syntax Error: t-string: expecting '}'