diff --git a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap index c9f840f58a885d..0c6999f0e6225f 100644 --- a/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap +++ b/crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F521_F521.py.snap @@ -9,7 +9,7 @@ F521.py:1:1: F521 `.format` call has invalid format string: Single '{' encounter 3 | "{foo[}".format(foo=1) | -F521.py:2:1: F521 `.format` call has invalid format string: Single '}' encountered in format string +F521.py:2:1: F521 `.format` call has invalid format string: Unexpected error parsing format string | 1 | "{".format(1) 2 | "}".format(1) diff --git a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap index 51bd88cd7f65d7..1ce1ef518b0bd0 100644 --- a/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap +++ b/crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap @@ -944,6 +944,26 @@ UP032_0.py:129:1: UP032 [*] Use f-string instead of `format` call 131 131 | ### 132 132 | # Non-errors +UP032_0.py:136:1: UP032 [*] Use f-string instead of `format` call + | +135 | # False-negative: RustPython doesn't parse the `\N{snowman}`. +136 | "\N{snowman} {}".format(a) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ UP032 +137 | +138 | "{".format(a) + | + = help: Convert to f-string + +ℹ Safe fix +133 133 | ### +134 134 | +135 135 | # False-negative: RustPython doesn't parse the `\N{snowman}`. +136 |-"\N{snowman} {}".format(a) + 136 |+f"\N{snowman} {a}" +137 137 | +138 138 | "{".format(a) +139 139 | + UP032_0.py:160:1: UP032 [*] Use f-string instead of `format` call | 158 | r'"\N{snowman} {}".format(a)' diff --git a/crates/ruff_python_literal/src/format.rs b/crates/ruff_python_literal/src/format.rs index 01f98587634c94..def8f777a93890 100644 --- a/crates/ruff_python_literal/src/format.rs +++ b/crates/ruff_python_literal/src/format.rs @@ -593,6 +593,38 @@ impl FormatString { let mut cur_text = text; let mut result_string = String::new(); while !cur_text.is_empty() { + // Check for Unicode escape sequences like \N{...} + if cur_text.starts_with("\\N{") { + result_string.push_str("\\N"); + let rest = &cur_text[2..]; + let mut chars = rest.chars(); + let next = chars.next(); + debug_assert!(next == Some('{')); + result_string.push('{'); + + loop { + match chars.next() { + Some('{') => { + return Err(FormatParseError::UnescapedStartBracketInLiteral); + } + Some('}') => { + result_string.push('}'); + let consumed = rest.len() - chars.as_str().len(); + cur_text = &rest[consumed..]; + break; + } + Some(ch) => { + result_string.push(ch); + } + None => { + cur_text = ""; + break; + } + } + } + continue; + } + match FormatString::parse_literal_single(cur_text) { Ok((next_char, remaining)) => { result_string.push(next_char); @@ -694,12 +726,28 @@ impl<'a> FromTemplate<'a> for FormatString { while !cur_text.is_empty() { // Try to parse both literals and bracketed format parts until we // run out of text - cur_text = FormatString::parse_literal(cur_text) - .or_else(|_| FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)) - .map(|(part, new_text)| { + match FormatString::parse_literal(cur_text) { + Ok((part, new_text)) => { + parts.push(part); + cur_text = new_text; + } + Err(FormatParseError::UnescapedStartBracketInLiteral) => { + if cur_text.starts_with('{') { + let (part, new_text) = + FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)?; + parts.push(part); + cur_text = new_text; + } else { + return Err(FormatParseError::UnescapedStartBracketInLiteral); + } + } + Err(_) => { + let (part, new_text) = + FormatString::parse_spec(cur_text, AllowPlaceholderNesting::Yes)?; parts.push(part); - new_text - })?; + cur_text = new_text; + } + } } Ok(FormatString { format_parts: parts, @@ -1020,4 +1068,43 @@ mod tests { Err(FormatParseError::InvalidCharacterAfterRightBracket) ); } + + #[test] + fn test_format_parse_unicode_escape() { + let result = FormatString::from_str("\\N{LATIN SMALL LETTER A}"); + assert!(result.is_ok()); + + let format_string = result.unwrap(); + assert_eq!(format_string.format_parts.len(), 1); + match &format_string.format_parts[0] { + FormatPart::Literal(literal) => { + assert_eq!(literal, "\\N{LATIN SMALL LETTER A}"); + } + FormatPart::Field { .. } => panic!("Expected literal part"), + } + } + + #[test] + fn test_format_parse_unicode_escape_incomplete() { + let result = FormatString::from_str("\\N{incomplete"); + assert!(result.is_ok()); + + let format_string = result.unwrap(); + assert_eq!(format_string.format_parts.len(), 1); + match &format_string.format_parts[0] { + FormatPart::Literal(literal) => { + assert_eq!(literal, "\\N{incomplete"); + } + FormatPart::Field { .. } => panic!("Expected literal part"), + } + } + + #[test] + fn test_format_parse_unicode_escape_nested() { + let result = FormatString::from_str("\\N{LATIN {SMALL} LETTER A}"); + assert_eq!( + result, + Err(FormatParseError::UnescapedStartBracketInLiteral) + ); + } }