diff --git a/crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py b/crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py new file mode 100644 index 0000000000000..bb74eb0b7b15b --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py @@ -0,0 +1,4 @@ +# parse_options: {"target-version": "3.8"} +f((a)=1) +f((a) = 1) +f( ( a ) = 1) diff --git a/crates/ruff_python_parser/resources/inline/ok/parenthesized_kwarg_py37.py b/crates/ruff_python_parser/resources/inline/ok/parenthesized_kwarg_py37.py new file mode 100644 index 0000000000000..6b3d964f7832e --- /dev/null +++ b/crates/ruff_python_parser/resources/inline/ok/parenthesized_kwarg_py37.py @@ -0,0 +1,2 @@ +# parse_options: {"target-version": "3.7"} +f((a)=1) diff --git a/crates/ruff_python_parser/src/error.rs b/crates/ruff_python_parser/src/error.rs index 79acba46162ef..1e8934d3d1235 100644 --- a/crates/ruff_python_parser/src/error.rs +++ b/crates/ruff_python_parser/src/error.rs @@ -436,11 +436,6 @@ pub struct UnsupportedSyntaxError { pub kind: UnsupportedSyntaxErrorKind, pub range: TextRange, /// The target [`PythonVersion`] for which this error was detected. - /// - /// This is different from the version reported by the - /// [`minimum_version`](UnsupportedSyntaxErrorKind::minimum_version) method, which is the - /// earliest allowed version for this piece of syntax. The `target_version` is primarily used - /// for user-facing error messages. pub target_version: PythonVersion, } @@ -457,6 +452,26 @@ pub enum UnsupportedSyntaxErrorKind { Walrus, ExceptStar, + /// Represents the use of a parenthesized keyword argument name after Python 3.8. + /// + /// ## Example + /// + /// From [BPO 34641] it sounds like this was only accidentally supported and was removed when + /// noticed. Code like this used to be valid: + /// + /// ```python + /// f((a)=1) + /// ``` + /// + /// After Python 3.8, you have to omit the parentheses around `a`: + /// + /// ```python + /// f(a=1) + /// ``` + /// + /// [BPO 34641]: https://github.com/python/cpython/issues/78822 + ParenthesizedKeywordArgumentName, + /// Represents the use of unparenthesized tuple unpacking in a `return` statement or `yield` /// expression before Python 3.8. /// @@ -603,6 +618,9 @@ impl Display for UnsupportedSyntaxError { UnsupportedSyntaxErrorKind::Match => "Cannot use `match` statement", UnsupportedSyntaxErrorKind::Walrus => "Cannot use named assignment expression (`:=`)", UnsupportedSyntaxErrorKind::ExceptStar => "Cannot use `except*`", + UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => { + "Cannot use parenthesized keyword argument name" + } UnsupportedSyntaxErrorKind::StarTuple(StarTupleKind::Return) => { "Cannot use iterable unpacking in return statements" } @@ -619,30 +637,65 @@ impl Display for UnsupportedSyntaxError { "Cannot set default type for a type parameter" } }; + write!( f, - "{kind} on Python {} (syntax was added in Python {})", + "{kind} on Python {} (syntax was {changed})", self.target_version, - self.kind.minimum_version(), + changed = self.kind.changed_version(), ) } } +/// Represents the kind of change in Python syntax between versions. +enum Change { + Added(PythonVersion), + Removed(PythonVersion), +} + +impl Display for Change { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Change::Added(version) => write!(f, "added in Python {version}"), + Change::Removed(version) => write!(f, "removed in Python {version}"), + } + } +} + impl UnsupportedSyntaxErrorKind { - /// The earliest allowed version for the syntax associated with this error. - pub const fn minimum_version(&self) -> PythonVersion { + /// Returns the Python version when the syntax associated with this error was changed, and the + /// type of [`Change`] (added or removed). + const fn changed_version(self) -> Change { match self { - UnsupportedSyntaxErrorKind::Match => PythonVersion::PY310, - UnsupportedSyntaxErrorKind::Walrus => PythonVersion::PY38, - UnsupportedSyntaxErrorKind::ExceptStar => PythonVersion::PY311, - UnsupportedSyntaxErrorKind::StarTuple(_) => PythonVersion::PY38, - UnsupportedSyntaxErrorKind::RelaxedDecorator => PythonVersion::PY39, - UnsupportedSyntaxErrorKind::PositionalOnlyParameter => PythonVersion::PY38, - UnsupportedSyntaxErrorKind::TypeParameterList => PythonVersion::PY312, - UnsupportedSyntaxErrorKind::TypeAliasStatement => PythonVersion::PY312, - UnsupportedSyntaxErrorKind::TypeParamDefault => PythonVersion::PY313, + UnsupportedSyntaxErrorKind::Match => Change::Added(PythonVersion::PY310), + UnsupportedSyntaxErrorKind::Walrus => Change::Added(PythonVersion::PY38), + UnsupportedSyntaxErrorKind::ExceptStar => Change::Added(PythonVersion::PY311), + UnsupportedSyntaxErrorKind::StarTuple(_) => Change::Added(PythonVersion::PY38), + UnsupportedSyntaxErrorKind::RelaxedDecorator => Change::Added(PythonVersion::PY39), + UnsupportedSyntaxErrorKind::PositionalOnlyParameter => { + Change::Added(PythonVersion::PY38) + } + UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName => { + Change::Removed(PythonVersion::PY38) + } + UnsupportedSyntaxErrorKind::TypeParameterList => Change::Added(PythonVersion::PY312), + UnsupportedSyntaxErrorKind::TypeAliasStatement => Change::Added(PythonVersion::PY312), + UnsupportedSyntaxErrorKind::TypeParamDefault => Change::Added(PythonVersion::PY313), } } + + /// Returns whether or not this kind of syntax is unsupported on `target_version`. + pub(crate) fn is_unsupported(self, target_version: PythonVersion) -> bool { + match self.changed_version() { + Change::Added(version) => target_version < version, + Change::Removed(version) => target_version >= version, + } + } + + /// Returns `true` if this kind of syntax is supported on `target_version`. + pub(crate) fn is_supported(self, target_version: PythonVersion) -> bool { + !self.is_unsupported(target_version) + } } #[cfg(target_pointer_width = "64")] diff --git a/crates/ruff_python_parser/src/parser/expression.rs b/crates/ruff_python_parser/src/parser/expression.rs index 9452d607ebf87..b5fa489af631c 100644 --- a/crates/ruff_python_parser/src/parser/expression.rs +++ b/crates/ruff_python_parser/src/parser/expression.rs @@ -702,9 +702,31 @@ impl<'src> Parser<'src> { } } + let arg_range = parser.node_range(start); if parser.eat(TokenKind::Equal) { seen_keyword_argument = true; - let arg = if let Expr::Name(ident_expr) = parsed_expr.expr { + let arg = if let ParsedExpr { + expr: Expr::Name(ident_expr), + is_parenthesized, + } = parsed_expr + { + // test_ok parenthesized_kwarg_py37 + // # parse_options: {"target-version": "3.7"} + // f((a)=1) + + // test_err parenthesized_kwarg_py38 + // # parse_options: {"target-version": "3.8"} + // f((a)=1) + // f((a) = 1) + // f( ( a ) = 1) + + if is_parenthesized { + parser.add_unsupported_syntax_error( + UnsupportedSyntaxErrorKind::ParenthesizedKeywordArgumentName, + arg_range, + ); + } + ast::Identifier { id: ident_expr.id, range: ident_expr.range, diff --git a/crates/ruff_python_parser/src/parser/mod.rs b/crates/ruff_python_parser/src/parser/mod.rs index 78bf047d463e5..5ad6557d92098 100644 --- a/crates/ruff_python_parser/src/parser/mod.rs +++ b/crates/ruff_python_parser/src/parser/mod.rs @@ -441,7 +441,7 @@ impl<'src> Parser<'src> { /// Add an [`UnsupportedSyntaxError`] with the given [`UnsupportedSyntaxErrorKind`] and /// [`TextRange`] if its minimum version is less than [`Parser::target_version`]. fn add_unsupported_syntax_error(&mut self, kind: UnsupportedSyntaxErrorKind, range: TextRange) { - if self.options.target_version < kind.minimum_version() { + if kind.is_unsupported(self.options.target_version) { self.unsupported_syntax_errors.push(UnsupportedSyntaxError { kind, range, diff --git a/crates/ruff_python_parser/src/parser/statement.rs b/crates/ruff_python_parser/src/parser/statement.rs index 4a0d9f048e05d..f9bde0aa0407c 100644 --- a/crates/ruff_python_parser/src/parser/statement.rs +++ b/crates/ruff_python_parser/src/parser/statement.rs @@ -424,7 +424,7 @@ impl<'src> Parser<'src> { /// are only allowed in Python 3.8 and later: . pub(crate) fn check_tuple_unpacking(&mut self, expr: &Expr, kind: StarTupleKind) { let kind = UnsupportedSyntaxErrorKind::StarTuple(kind); - if self.options.target_version >= kind.minimum_version() { + if kind.is_supported(self.options.target_version) { return; } diff --git a/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_kwarg_py38.py.snap b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_kwarg_py38.py.snap new file mode 100644 index 0000000000000..4b55ae1a8a6ff --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/invalid_syntax@parenthesized_kwarg_py38.py.snap @@ -0,0 +1,161 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/err/parenthesized_kwarg_py38.py +--- +## AST + +``` +Module( + ModModule { + range: 0..77, + body: [ + Expr( + StmtExpr { + range: 43..51, + value: Call( + ExprCall { + range: 43..51, + func: Name( + ExprName { + range: 43..44, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 44..51, + args: [], + keywords: [ + Keyword { + range: 45..50, + arg: Some( + Identifier { + id: Name("a"), + range: 46..47, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + range: 49..50, + value: Int( + 1, + ), + }, + ), + }, + ], + }, + }, + ), + }, + ), + Expr( + StmtExpr { + range: 52..62, + value: Call( + ExprCall { + range: 52..62, + func: Name( + ExprName { + range: 52..53, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 53..62, + args: [], + keywords: [ + Keyword { + range: 54..61, + arg: Some( + Identifier { + id: Name("a"), + range: 55..56, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + range: 60..61, + value: Int( + 1, + ), + }, + ), + }, + ], + }, + }, + ), + }, + ), + Expr( + StmtExpr { + range: 63..76, + value: Call( + ExprCall { + range: 63..76, + func: Name( + ExprName { + range: 63..64, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 64..76, + args: [], + keywords: [ + Keyword { + range: 66..75, + arg: Some( + Identifier { + id: Name("a"), + range: 68..69, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + range: 74..75, + value: Int( + 1, + ), + }, + ), + }, + ], + }, + }, + ), + }, + ), + ], + }, +) +``` +## Unsupported Syntax Errors + + | +1 | # parse_options: {"target-version": "3.8"} +2 | f((a)=1) + | ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8) +3 | f((a) = 1) +4 | f( ( a ) = 1) + | + + + | +1 | # parse_options: {"target-version": "3.8"} +2 | f((a)=1) +3 | f((a) = 1) + | ^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8) +4 | f( ( a ) = 1) + | + + + | +2 | f((a)=1) +3 | f((a) = 1) +4 | f( ( a ) = 1) + | ^^^^^ Syntax Error: Cannot use parenthesized keyword argument name on Python 3.8 (syntax was removed in Python 3.8) + | diff --git a/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_kwarg_py37.py.snap b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_kwarg_py37.py.snap new file mode 100644 index 0000000000000..bb42e78d8c5a9 --- /dev/null +++ b/crates/ruff_python_parser/tests/snapshots/valid_syntax@parenthesized_kwarg_py37.py.snap @@ -0,0 +1,55 @@ +--- +source: crates/ruff_python_parser/tests/fixtures.rs +input_file: crates/ruff_python_parser/resources/inline/ok/parenthesized_kwarg_py37.py +--- +## AST + +``` +Module( + ModModule { + range: 0..52, + body: [ + Expr( + StmtExpr { + range: 43..51, + value: Call( + ExprCall { + range: 43..51, + func: Name( + ExprName { + range: 43..44, + id: Name("f"), + ctx: Load, + }, + ), + arguments: Arguments { + range: 44..51, + args: [], + keywords: [ + Keyword { + range: 45..50, + arg: Some( + Identifier { + id: Name("a"), + range: 46..47, + }, + ), + value: NumberLiteral( + ExprNumberLiteral { + range: 49..50, + value: Int( + 1, + ), + }, + ), + }, + ], + }, + }, + ), + }, + ), + ], + }, +) +```