@@ -8,6 +8,7 @@ use std::fmt::Display;
8
8
9
9
use ruff_python_ast:: {
10
10
self as ast,
11
+ comparable:: ComparableExpr ,
11
12
visitor:: { walk_expr, Visitor } ,
12
13
Expr , ExprContext , IrrefutablePatternKind , Pattern , PythonVersion , Stmt , StmtExpr ,
13
14
StmtImportFrom ,
@@ -65,6 +66,7 @@ impl SemanticSyntaxChecker {
65
66
Stmt :: Match ( match_stmt) => {
66
67
Self :: irrefutable_match_case ( match_stmt, ctx) ;
67
68
Self :: multiple_case_assignment ( match_stmt, ctx) ;
69
+ Self :: duplicate_match_mapping_keys ( match_stmt, ctx) ;
68
70
}
69
71
Stmt :: FunctionDef ( ast:: StmtFunctionDef { type_params, .. } )
70
72
| Stmt :: ClassDef ( ast:: StmtClassDef { type_params, .. } )
@@ -270,6 +272,58 @@ impl SemanticSyntaxChecker {
270
272
}
271
273
}
272
274
275
+ fn duplicate_match_mapping_keys < Ctx : SemanticSyntaxContext > ( stmt : & ast:: StmtMatch , ctx : & Ctx ) {
276
+ for mapping in stmt
277
+ . cases
278
+ . iter ( )
279
+ . filter_map ( |case| case. pattern . as_match_mapping ( ) )
280
+ {
281
+ let mut seen = FxHashSet :: default ( ) ;
282
+ for key in mapping
283
+ . keys
284
+ . iter ( )
285
+ // complex numbers (`1 + 2j`) are allowed as keys but are not literals
286
+ // because they are represented as a `BinOp::Add` between a real number and
287
+ // an imaginary number
288
+ . filter ( |key| key. is_literal_expr ( ) || key. is_bin_op_expr ( ) )
289
+ {
290
+ if !seen. insert ( ComparableExpr :: from ( key) ) {
291
+ let key_range = key. range ( ) ;
292
+ let duplicate_key = ctx. source ( ) [ key_range] . to_string ( ) ;
293
+ // test_ok duplicate_match_key_attr
294
+ // match x:
295
+ // case {x.a: 1, x.a: 2}: ...
296
+
297
+ // test_err duplicate_match_key
298
+ // match x:
299
+ // case {"x": 1, "x": 2}: ...
300
+ // case {b"x": 1, b"x": 2}: ...
301
+ // case {0: 1, 0: 2}: ...
302
+ // case {1.0: 1, 1.0: 2}: ...
303
+ // case {1.0 + 2j: 1, 1.0 + 2j: 2}: ...
304
+ // case {True: 1, True: 2}: ...
305
+ // case {None: 1, None: 2}: ...
306
+ // case {
307
+ // """x
308
+ // y
309
+ // z
310
+ // """: 1,
311
+ // """x
312
+ // y
313
+ // z
314
+ // """: 2}: ...
315
+ // case {"x": 1, "x": 2, "x": 3}: ...
316
+ // case {0: 1, "x": 1, 0: 2, "x": 2}: ...
317
+ Self :: add_error (
318
+ ctx,
319
+ SemanticSyntaxErrorKind :: DuplicateMatchKey ( duplicate_key) ,
320
+ key_range,
321
+ ) ;
322
+ }
323
+ }
324
+ }
325
+ }
326
+
273
327
fn irrefutable_match_case < Ctx : SemanticSyntaxContext > ( stmt : & ast:: StmtMatch , ctx : & Ctx ) {
274
328
// test_ok irrefutable_case_pattern_at_end
275
329
// match x:
@@ -514,6 +568,13 @@ impl Display for SemanticSyntaxError {
514
568
write ! ( f, "cannot delete `__debug__` on Python {python_version} (syntax was removed in 3.9)" )
515
569
}
516
570
} ,
571
+ SemanticSyntaxErrorKind :: DuplicateMatchKey ( key) => {
572
+ write ! (
573
+ f,
574
+ "mapping pattern checks duplicate key `{}`" ,
575
+ EscapeDefault ( key)
576
+ )
577
+ }
517
578
SemanticSyntaxErrorKind :: LoadBeforeGlobalDeclaration { name, start : _ } => {
518
579
write ! ( f, "name `{name}` is used prior to global declaration" )
519
580
}
@@ -634,6 +695,41 @@ pub enum SemanticSyntaxErrorKind {
634
695
/// [BPO 45000]: https://github.com/python/cpython/issues/89163
635
696
WriteToDebug ( WriteToDebugKind ) ,
636
697
698
+ /// Represents a duplicate key in a `match` mapping pattern.
699
+ ///
700
+ /// The [CPython grammar] allows keys in mapping patterns to be literals or attribute accesses:
701
+ ///
702
+ /// ```text
703
+ /// key_value_pattern:
704
+ /// | (literal_expr | attr) ':' pattern
705
+ /// ```
706
+ ///
707
+ /// But only literals are checked for duplicates:
708
+ ///
709
+ /// ```pycon
710
+ /// >>> match x:
711
+ /// ... case {"x": 1, "x": 2}: ...
712
+ /// ...
713
+ /// File "<python-input-160>", line 2
714
+ /// case {"x": 1, "x": 2}: ...
715
+ /// ^^^^^^^^^^^^^^^^
716
+ /// SyntaxError: mapping pattern checks duplicate key ('x')
717
+ /// >>> match x:
718
+ /// ... case {x.a: 1, x.a: 2}: ...
719
+ /// ...
720
+ /// >>>
721
+ /// ```
722
+ ///
723
+ /// ## Examples
724
+ ///
725
+ /// ```python
726
+ /// match x:
727
+ /// case {"x": 1, "x": 2}: ...
728
+ /// ```
729
+ ///
730
+ /// [CPython grammar]: https://docs.python.org/3/reference/grammar.html
731
+ DuplicateMatchKey ( String ) ,
732
+
637
733
/// Represents the use of a `global` variable before its `global` declaration.
638
734
///
639
735
/// ## Examples
@@ -789,6 +885,9 @@ pub trait SemanticSyntaxContext {
789
885
/// The target Python version for detecting backwards-incompatible syntax changes.
790
886
fn python_version ( & self ) -> PythonVersion ;
791
887
888
+ /// Returns the source text under analysis.
889
+ fn source ( & self ) -> & str ;
890
+
792
891
/// Return the [`TextRange`] at which a name is declared as `global` in the current scope.
793
892
fn global ( & self , name : & str ) -> Option < TextRange > ;
794
893
@@ -828,3 +927,20 @@ where
828
927
ruff_python_ast:: visitor:: walk_expr ( self , expr) ;
829
928
}
830
929
}
930
+
931
+ /// Modified version of [`std::str::EscapeDefault`] that does not escape single or double quotes.
932
+ struct EscapeDefault < ' a > ( & ' a str ) ;
933
+
934
+ impl Display for EscapeDefault < ' _ > {
935
+ fn fmt ( & self , f : & mut std:: fmt:: Formatter < ' _ > ) -> std:: fmt:: Result {
936
+ use std:: fmt:: Write ;
937
+
938
+ for c in self . 0 . chars ( ) {
939
+ match c {
940
+ '\'' | '\"' => f. write_char ( c) ?,
941
+ _ => write ! ( f, "{}" , c. escape_default( ) ) ?,
942
+ }
943
+ }
944
+ Ok ( ( ) )
945
+ }
946
+ }
0 commit comments