mirror of
https://github.com/astral-sh/ruff.git
synced 2025-09-26 11:59:35 +00:00
[syntax-errors] Detect duplicate keys in match
mapping patterns (#17129)
Summary -- Detects duplicate literals in `match` mapping keys. This PR also adds a `source` method to `SemanticSyntaxContext` to display the duplicated key in the error message by slicing out its range. Test Plan -- New inline tests.
This commit is contained in:
parent
ca0cce3f9c
commit
6e2b8f9696
7 changed files with 1298 additions and 6 deletions
|
@ -573,6 +573,7 @@ impl SemanticSyntaxContext for Checker<'_> {
|
||||||
| SemanticSyntaxErrorKind::IrrefutableCasePattern(_)
|
| SemanticSyntaxErrorKind::IrrefutableCasePattern(_)
|
||||||
| SemanticSyntaxErrorKind::SingleStarredAssignment
|
| SemanticSyntaxErrorKind::SingleStarredAssignment
|
||||||
| SemanticSyntaxErrorKind::WriteToDebug(_)
|
| SemanticSyntaxErrorKind::WriteToDebug(_)
|
||||||
|
| SemanticSyntaxErrorKind::DuplicateMatchKey(_)
|
||||||
| SemanticSyntaxErrorKind::InvalidStarExpression => {
|
| SemanticSyntaxErrorKind::InvalidStarExpression => {
|
||||||
if self.settings.preview.is_enabled() {
|
if self.settings.preview.is_enabled() {
|
||||||
self.semantic_errors.borrow_mut().push(error);
|
self.semantic_errors.borrow_mut().push(error);
|
||||||
|
@ -580,6 +581,10 @@ impl SemanticSyntaxContext for Checker<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn source(&self) -> &str {
|
||||||
|
self.source()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> Visitor<'a> for Checker<'a> {
|
impl<'a> Visitor<'a> for Checker<'a> {
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
match x:
|
||||||
|
case {"x": 1, "x": 2}: ...
|
||||||
|
case {b"x": 1, b"x": 2}: ...
|
||||||
|
case {0: 1, 0: 2}: ...
|
||||||
|
case {1.0: 1, 1.0: 2}: ...
|
||||||
|
case {1.0 + 2j: 1, 1.0 + 2j: 2}: ...
|
||||||
|
case {True: 1, True: 2}: ...
|
||||||
|
case {None: 1, None: 2}: ...
|
||||||
|
case {
|
||||||
|
"""x
|
||||||
|
y
|
||||||
|
z
|
||||||
|
""": 1,
|
||||||
|
"""x
|
||||||
|
y
|
||||||
|
z
|
||||||
|
""": 2}: ...
|
||||||
|
case {"x": 1, "x": 2, "x": 3}: ...
|
||||||
|
case {0: 1, "x": 1, 0: 2, "x": 2}: ...
|
|
@ -0,0 +1,2 @@
|
||||||
|
match x:
|
||||||
|
case {x.a: 1, x.a: 2}: ...
|
|
@ -8,6 +8,7 @@ use std::fmt::Display;
|
||||||
|
|
||||||
use ruff_python_ast::{
|
use ruff_python_ast::{
|
||||||
self as ast,
|
self as ast,
|
||||||
|
comparable::ComparableExpr,
|
||||||
visitor::{walk_expr, Visitor},
|
visitor::{walk_expr, Visitor},
|
||||||
Expr, ExprContext, IrrefutablePatternKind, Pattern, PythonVersion, Stmt, StmtExpr,
|
Expr, ExprContext, IrrefutablePatternKind, Pattern, PythonVersion, Stmt, StmtExpr,
|
||||||
StmtImportFrom,
|
StmtImportFrom,
|
||||||
|
@ -65,6 +66,7 @@ impl SemanticSyntaxChecker {
|
||||||
Stmt::Match(match_stmt) => {
|
Stmt::Match(match_stmt) => {
|
||||||
Self::irrefutable_match_case(match_stmt, ctx);
|
Self::irrefutable_match_case(match_stmt, ctx);
|
||||||
Self::multiple_case_assignment(match_stmt, ctx);
|
Self::multiple_case_assignment(match_stmt, ctx);
|
||||||
|
Self::duplicate_match_mapping_keys(match_stmt, ctx);
|
||||||
}
|
}
|
||||||
Stmt::FunctionDef(ast::StmtFunctionDef { type_params, .. })
|
Stmt::FunctionDef(ast::StmtFunctionDef { type_params, .. })
|
||||||
| Stmt::ClassDef(ast::StmtClassDef { type_params, .. })
|
| Stmt::ClassDef(ast::StmtClassDef { type_params, .. })
|
||||||
|
@ -270,6 +272,58 @@ impl SemanticSyntaxChecker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn duplicate_match_mapping_keys<Ctx: SemanticSyntaxContext>(stmt: &ast::StmtMatch, ctx: &Ctx) {
|
||||||
|
for mapping in stmt
|
||||||
|
.cases
|
||||||
|
.iter()
|
||||||
|
.filter_map(|case| case.pattern.as_match_mapping())
|
||||||
|
{
|
||||||
|
let mut seen = FxHashSet::default();
|
||||||
|
for key in mapping
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
// complex numbers (`1 + 2j`) are allowed as keys but are not literals
|
||||||
|
// because they are represented as a `BinOp::Add` between a real number and
|
||||||
|
// an imaginary number
|
||||||
|
.filter(|key| key.is_literal_expr() || key.is_bin_op_expr())
|
||||||
|
{
|
||||||
|
if !seen.insert(ComparableExpr::from(key)) {
|
||||||
|
let key_range = key.range();
|
||||||
|
let duplicate_key = ctx.source()[key_range].to_string();
|
||||||
|
// test_ok duplicate_match_key_attr
|
||||||
|
// match x:
|
||||||
|
// case {x.a: 1, x.a: 2}: ...
|
||||||
|
|
||||||
|
// test_err duplicate_match_key
|
||||||
|
// match x:
|
||||||
|
// case {"x": 1, "x": 2}: ...
|
||||||
|
// case {b"x": 1, b"x": 2}: ...
|
||||||
|
// case {0: 1, 0: 2}: ...
|
||||||
|
// case {1.0: 1, 1.0: 2}: ...
|
||||||
|
// case {1.0 + 2j: 1, 1.0 + 2j: 2}: ...
|
||||||
|
// case {True: 1, True: 2}: ...
|
||||||
|
// case {None: 1, None: 2}: ...
|
||||||
|
// case {
|
||||||
|
// """x
|
||||||
|
// y
|
||||||
|
// z
|
||||||
|
// """: 1,
|
||||||
|
// """x
|
||||||
|
// y
|
||||||
|
// z
|
||||||
|
// """: 2}: ...
|
||||||
|
// case {"x": 1, "x": 2, "x": 3}: ...
|
||||||
|
// case {0: 1, "x": 1, 0: 2, "x": 2}: ...
|
||||||
|
Self::add_error(
|
||||||
|
ctx,
|
||||||
|
SemanticSyntaxErrorKind::DuplicateMatchKey(duplicate_key),
|
||||||
|
key_range,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn irrefutable_match_case<Ctx: SemanticSyntaxContext>(stmt: &ast::StmtMatch, ctx: &Ctx) {
|
fn irrefutable_match_case<Ctx: SemanticSyntaxContext>(stmt: &ast::StmtMatch, ctx: &Ctx) {
|
||||||
// test_ok irrefutable_case_pattern_at_end
|
// test_ok irrefutable_case_pattern_at_end
|
||||||
// match x:
|
// match x:
|
||||||
|
@ -514,6 +568,13 @@ impl Display for SemanticSyntaxError {
|
||||||
write!(f, "cannot delete `__debug__` on Python {python_version} (syntax was removed in 3.9)")
|
write!(f, "cannot delete `__debug__` on Python {python_version} (syntax was removed in 3.9)")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
SemanticSyntaxErrorKind::DuplicateMatchKey(key) => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"mapping pattern checks duplicate key `{}`",
|
||||||
|
EscapeDefault(key)
|
||||||
|
)
|
||||||
|
}
|
||||||
SemanticSyntaxErrorKind::LoadBeforeGlobalDeclaration { name, start: _ } => {
|
SemanticSyntaxErrorKind::LoadBeforeGlobalDeclaration { name, start: _ } => {
|
||||||
write!(f, "name `{name}` is used prior to global declaration")
|
write!(f, "name `{name}` is used prior to global declaration")
|
||||||
}
|
}
|
||||||
|
@ -634,6 +695,41 @@ pub enum SemanticSyntaxErrorKind {
|
||||||
/// [BPO 45000]: https://github.com/python/cpython/issues/89163
|
/// [BPO 45000]: https://github.com/python/cpython/issues/89163
|
||||||
WriteToDebug(WriteToDebugKind),
|
WriteToDebug(WriteToDebugKind),
|
||||||
|
|
||||||
|
/// Represents a duplicate key in a `match` mapping pattern.
|
||||||
|
///
|
||||||
|
/// The [CPython grammar] allows keys in mapping patterns to be literals or attribute accesses:
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// key_value_pattern:
|
||||||
|
/// | (literal_expr | attr) ':' pattern
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// But only literals are checked for duplicates:
|
||||||
|
///
|
||||||
|
/// ```pycon
|
||||||
|
/// >>> match x:
|
||||||
|
/// ... case {"x": 1, "x": 2}: ...
|
||||||
|
/// ...
|
||||||
|
/// File "<python-input-160>", line 2
|
||||||
|
/// case {"x": 1, "x": 2}: ...
|
||||||
|
/// ^^^^^^^^^^^^^^^^
|
||||||
|
/// SyntaxError: mapping pattern checks duplicate key ('x')
|
||||||
|
/// >>> match x:
|
||||||
|
/// ... case {x.a: 1, x.a: 2}: ...
|
||||||
|
/// ...
|
||||||
|
/// >>>
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ## Examples
|
||||||
|
///
|
||||||
|
/// ```python
|
||||||
|
/// match x:
|
||||||
|
/// case {"x": 1, "x": 2}: ...
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// [CPython grammar]: https://docs.python.org/3/reference/grammar.html
|
||||||
|
DuplicateMatchKey(String),
|
||||||
|
|
||||||
/// Represents the use of a `global` variable before its `global` declaration.
|
/// Represents the use of a `global` variable before its `global` declaration.
|
||||||
///
|
///
|
||||||
/// ## Examples
|
/// ## Examples
|
||||||
|
@ -789,6 +885,9 @@ pub trait SemanticSyntaxContext {
|
||||||
/// The target Python version for detecting backwards-incompatible syntax changes.
|
/// The target Python version for detecting backwards-incompatible syntax changes.
|
||||||
fn python_version(&self) -> PythonVersion;
|
fn python_version(&self) -> PythonVersion;
|
||||||
|
|
||||||
|
/// Returns the source text under analysis.
|
||||||
|
fn source(&self) -> &str;
|
||||||
|
|
||||||
/// Return the [`TextRange`] at which a name is declared as `global` in the current scope.
|
/// Return the [`TextRange`] at which a name is declared as `global` in the current scope.
|
||||||
fn global(&self, name: &str) -> Option<TextRange>;
|
fn global(&self, name: &str) -> Option<TextRange>;
|
||||||
|
|
||||||
|
@ -828,3 +927,20 @@ where
|
||||||
ruff_python_ast::visitor::walk_expr(self, expr);
|
ruff_python_ast::visitor::walk_expr(self, expr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Modified version of [`std::str::EscapeDefault`] that does not escape single or double quotes.
|
||||||
|
struct EscapeDefault<'a>(&'a str);
|
||||||
|
|
||||||
|
impl Display for EscapeDefault<'_> {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
use std::fmt::Write;
|
||||||
|
|
||||||
|
for c in self.0.chars() {
|
||||||
|
match c {
|
||||||
|
'\'' | '\"' => f.write_char(c)?,
|
||||||
|
_ => write!(f, "{}", c.escape_default())?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -89,7 +89,7 @@ fn test_valid_syntax(input_path: &Path) {
|
||||||
let parsed = parsed.try_into_module().expect("Parsed with Mode::Module");
|
let parsed = parsed.try_into_module().expect("Parsed with Mode::Module");
|
||||||
|
|
||||||
let mut visitor = SemanticSyntaxCheckerVisitor::new(
|
let mut visitor = SemanticSyntaxCheckerVisitor::new(
|
||||||
TestContext::default().with_python_version(options.target_version()),
|
TestContext::new(&source).with_python_version(options.target_version()),
|
||||||
);
|
);
|
||||||
|
|
||||||
for stmt in parsed.suite() {
|
for stmt in parsed.suite() {
|
||||||
|
@ -185,7 +185,7 @@ fn test_invalid_syntax(input_path: &Path) {
|
||||||
let parsed = parsed.try_into_module().expect("Parsed with Mode::Module");
|
let parsed = parsed.try_into_module().expect("Parsed with Mode::Module");
|
||||||
|
|
||||||
let mut visitor = SemanticSyntaxCheckerVisitor::new(
|
let mut visitor = SemanticSyntaxCheckerVisitor::new(
|
||||||
TestContext::default().with_python_version(options.target_version()),
|
TestContext::new(&source).with_python_version(options.target_version()),
|
||||||
);
|
);
|
||||||
|
|
||||||
for stmt in parsed.suite() {
|
for stmt in parsed.suite() {
|
||||||
|
@ -462,13 +462,22 @@ impl<'ast> SourceOrderVisitor<'ast> for ValidateAstVisitor<'ast> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug)]
|
||||||
struct TestContext {
|
struct TestContext<'a> {
|
||||||
diagnostics: RefCell<Vec<SemanticSyntaxError>>,
|
diagnostics: RefCell<Vec<SemanticSyntaxError>>,
|
||||||
python_version: PythonVersion,
|
python_version: PythonVersion,
|
||||||
|
source: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestContext {
|
impl<'a> TestContext<'a> {
|
||||||
|
fn new(source: &'a str) -> Self {
|
||||||
|
Self {
|
||||||
|
diagnostics: RefCell::default(),
|
||||||
|
python_version: PythonVersion::default(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
fn with_python_version(mut self, python_version: PythonVersion) -> Self {
|
fn with_python_version(mut self, python_version: PythonVersion) -> Self {
|
||||||
self.python_version = python_version;
|
self.python_version = python_version;
|
||||||
|
@ -476,7 +485,7 @@ impl TestContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SemanticSyntaxContext for TestContext {
|
impl SemanticSyntaxContext for TestContext<'_> {
|
||||||
fn seen_docstring_boundary(&self) -> bool {
|
fn seen_docstring_boundary(&self) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
@ -489,6 +498,10 @@ impl SemanticSyntaxContext for TestContext {
|
||||||
self.diagnostics.borrow_mut().push(error);
|
self.diagnostics.borrow_mut().push(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn source(&self) -> &str {
|
||||||
|
self.source
|
||||||
|
}
|
||||||
|
|
||||||
fn global(&self, _name: &str) -> Option<TextRange> {
|
fn global(&self, _name: &str) -> Option<TextRange> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,115 @@
|
||||||
|
---
|
||||||
|
source: crates/ruff_python_parser/tests/fixtures.rs
|
||||||
|
input_file: crates/ruff_python_parser/resources/inline/ok/duplicate_match_key_attr.py
|
||||||
|
---
|
||||||
|
## AST
|
||||||
|
|
||||||
|
```
|
||||||
|
Module(
|
||||||
|
ModModule {
|
||||||
|
range: 0..40,
|
||||||
|
body: [
|
||||||
|
Match(
|
||||||
|
StmtMatch {
|
||||||
|
range: 0..39,
|
||||||
|
subject: Name(
|
||||||
|
ExprName {
|
||||||
|
range: 6..7,
|
||||||
|
id: Name("x"),
|
||||||
|
ctx: Load,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
cases: [
|
||||||
|
MatchCase {
|
||||||
|
range: 13..39,
|
||||||
|
pattern: MatchMapping(
|
||||||
|
PatternMatchMapping {
|
||||||
|
range: 18..34,
|
||||||
|
keys: [
|
||||||
|
Attribute(
|
||||||
|
ExprAttribute {
|
||||||
|
range: 19..22,
|
||||||
|
value: Name(
|
||||||
|
ExprName {
|
||||||
|
range: 19..20,
|
||||||
|
id: Name("x"),
|
||||||
|
ctx: Load,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
attr: Identifier {
|
||||||
|
id: Name("a"),
|
||||||
|
range: 21..22,
|
||||||
|
},
|
||||||
|
ctx: Load,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Attribute(
|
||||||
|
ExprAttribute {
|
||||||
|
range: 27..30,
|
||||||
|
value: Name(
|
||||||
|
ExprName {
|
||||||
|
range: 27..28,
|
||||||
|
id: Name("x"),
|
||||||
|
ctx: Load,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
attr: Identifier {
|
||||||
|
id: Name("a"),
|
||||||
|
range: 29..30,
|
||||||
|
},
|
||||||
|
ctx: Load,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
patterns: [
|
||||||
|
MatchValue(
|
||||||
|
PatternMatchValue {
|
||||||
|
range: 24..25,
|
||||||
|
value: NumberLiteral(
|
||||||
|
ExprNumberLiteral {
|
||||||
|
range: 24..25,
|
||||||
|
value: Int(
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
MatchValue(
|
||||||
|
PatternMatchValue {
|
||||||
|
range: 32..33,
|
||||||
|
value: NumberLiteral(
|
||||||
|
ExprNumberLiteral {
|
||||||
|
range: 32..33,
|
||||||
|
value: Int(
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
rest: None,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
guard: None,
|
||||||
|
body: [
|
||||||
|
Expr(
|
||||||
|
StmtExpr {
|
||||||
|
range: 36..39,
|
||||||
|
value: EllipsisLiteral(
|
||||||
|
ExprEllipsisLiteral {
|
||||||
|
range: 36..39,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
Loading…
Add table
Add a link
Reference in a new issue