diff --git a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py index c1ac839e27f3d9..d2ffaa73528c8c 100644 --- a/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py +++ b/crates/ruff_linter/resources/test/fixtures/flake8_comprehensions/C408.py @@ -37,3 +37,12 @@ def list(): t"{ dict(x='y') | dict(y='z') }" t"a {dict(x='y') | dict(y='z')} b" t"a { dict(x='y') | dict(y='z') } b" + +# https://github.com/astral-sh/ruff/issues/16234 +# Python normalizes identifiers to NFKC, but does not normalize string literals, so the fix has to +# normalize the keyword name to preserve the dictionary key at runtime. The character "ℼ" normalizes +# to "π", and "ſ" normalizes to "s". +dict(ℼ=3.14) +dict(ſ=1) +dict(𝕒=1, b=2) +dict(a=1, b=2) # already NFKC-normalized: unchanged diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs index 32d850820b456f..e04d218d9029a4 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/fixes.rs @@ -14,6 +14,7 @@ use ruff_python_ast::{self as ast, Expr, ExprCall}; use ruff_python_codegen::Stylist; use ruff_python_semantic::SemanticModel; use ruff_text_size::{Ranged, TextRange}; +use unicode_normalization::UnicodeNormalization; use crate::Locator; use crate::cst::helpers::{negate, space}; @@ -241,6 +242,10 @@ pub(crate) fn fix_unnecessary_collection_call( .unwrap_or(stylist.quote()); // Quote each argument. + // + // Python normalizes identifiers to NFKC, but string literals are not normalized. Emitting the + // raw source text of a keyword argument would change the dictionary key at runtime, so the + // name has to be normalized. See https://github.com/astral-sh/ruff/issues/16234. for arg in &call.args { let quoted = format!( "{}{}{}", @@ -248,7 +253,8 @@ pub(crate) fn fix_unnecessary_collection_call( arg.keyword .as_ref() .expect("Expected dictionary argument to be kwarg") - .value, + .value + .nfkc(), quote, ); arena.push(quoted); diff --git a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap index d3fa03663c686c..0002bcc8387122 100644 --- a/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap +++ b/crates/ruff_linter/src/rules/flake8_comprehensions/snapshots/ruff_linter__rules__flake8_comprehensions__tests__C408_C408.py.snap @@ -529,12 +529,15 @@ C408 [*] Unnecessary `dict()` call (rewrite as a literal) 38 | t"a {dict(x='y') | dict(y='z')} b" 39 | t"a { dict(x='y') | dict(y='z') } b" | ^^^^^^^^^^^ +40 | +41 | # https://github.com/astral-sh/ruff/issues/16234 | help: Rewrite as a literal | 38 | t"a {dict(x='y') | dict(y='z')} b" - t"a { dict(x='y') | dict(y='z') } b" 39 + t"a { {'x': 'y'} | dict(y='z') } b" +40 | | note: This is an unsafe fix and may change runtime behavior @@ -545,11 +548,86 @@ C408 [*] Unnecessary `dict()` call (rewrite as a literal) 38 | t"a {dict(x='y') | dict(y='z')} b" 39 | t"a { dict(x='y') | dict(y='z') } b" | ^^^^^^^^^^^ +40 | +41 | # https://github.com/astral-sh/ruff/issues/16234 | help: Rewrite as a literal | 38 | t"a {dict(x='y') | dict(y='z')} b" - t"a { dict(x='y') | dict(y='z') } b" 39 + t"a { dict(x='y') | {'y': 'z'} } b" +40 | + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:45:1 + | +43 | # normalize the keyword name to preserve the dictionary key at runtime. The character "ℼ" normalizes +44 | # to "π", and "ſ" normalizes to "s". +45 | dict(ℼ=3.14) + | ^^^^^^^^^^^^ +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) + | +help: Rewrite as a literal + | +44 | # to "π", and "ſ" normalizes to "s". + - dict(ℼ=3.14) +45 + {"π": 3.14} +46 | dict(ſ=1) + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:46:1 + | +44 | # to "π", and "ſ" normalizes to "s". +45 | dict(ℼ=3.14) +46 | dict(ſ=1) + | ^^^^^^^^^ +47 | dict(𝕒=1, b=2) +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +help: Rewrite as a literal + | +45 | dict(ℼ=3.14) + - dict(ſ=1) +46 + {"s": 1} +47 | dict(𝕒=1, b=2) + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:47:1 + | +45 | dict(ℼ=3.14) +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) + | ^^^^^^^^^^^^^^ +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +help: Rewrite as a literal + | +46 | dict(ſ=1) + - dict(𝕒=1, b=2) +47 + {"a": 1, "b": 2} +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | +note: This is an unsafe fix and may change runtime behavior + +C408 [*] Unnecessary `dict()` call (rewrite as a literal) + --> C408.py:48:1 + | +46 | dict(ſ=1) +47 | dict(𝕒=1, b=2) +48 | dict(a=1, b=2) # already NFKC-normalized: unchanged + | ^^^^^^^^^^^^^^ + | +help: Rewrite as a literal + | +47 | dict(𝕒=1, b=2) + - dict(a=1, b=2) # already NFKC-normalized: unchanged +48 + {"a": 1, "b": 2} # already NFKC-normalized: unchanged | note: This is an unsafe fix and may change runtime behavior