Skip to content

Commit d0f2a8e

Browse files
authored
Add support for nested replacements inside format specifications (#6616)
Closes #6442 Python string formatting like `"hello {place}".format(place="world")` supports format specifications for replaced content such as `"hello {place:>10}".format(place="world")` which will align the text to the right in a container filled up to ten characters. Ruff parses formatted strings into `FormatPart`s each of which is either a `Field` (content in `{...}`) or a `Literal` (the normal content). Fields are parsed into name and format specifier sections (we'll ignore conversion specifiers for now). There are a myriad of specifiers that can be used in a `FormatSpec`. Unfortunately for linters, the specifier values can be dynamically set. For example, `"hello {place:{align}{width}}".format(place="world", align=">", width=10)` and `"hello {place:{fmt}}".format(place="world", fmt=">10")` will yield the same string as before but variables can be used to determine the formatting. In this case, when parsing the format specifier we can't know what _kind_ of specifier is being used as their meaning is determined by both position and value. Ruff does not support nested replacements and our current data model does not support the concept. Here the data model is updated to support this concept, although linting of specifications with replacements will be inherently limited. We could split format specifications into two types, one without any replacements that we can perform lints with and one with replacements that we cannot inspect. However, it seems excessive to drop all parsing of format specifiers due to the presence of a replacement. Instead, I've opted to parse replacements eagerly and ignore their possible effect on other format specifiers. This will allow us to retain a simple interface for `FormatSpec` and most syntax checks. We may need to add some handling to relax errors if a replacement was seen previously. It's worth noting that the nested replacement _can_ also include a format specification although it may fail at runtime if you produce an invalid outer format specification. For example, `"hello {place:{fmt:<2}}".format(place="world", fmt=">10")` is valid so we need to represent each nested replacement as a full `FormatPart`. ## Test plan Adding unit tests for `FormatSpec` parsing and snapshots for PLE1300
1 parent 1334232 commit d0f2a8e

File tree

6 files changed

+266
-39
lines changed

6 files changed

+266
-39
lines changed

crates/ruff/resources/test/fixtures/pylint/bad_string_format_character.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414

1515
"{:s} {:y}".format("hello", "world") # [bad-format-character]
1616

17-
"{:*^30s}".format("centered")
17+
"{:*^30s}".format("centered") # OK
18+
"{:{s}}".format("hello", s="s") # OK (nested replacement value not checked)
19+
20+
"{:{s:y}}".format("hello", s="s") # [bad-format-character] (nested replacement format spec checked)
1821

1922
## f-strings
2023

crates/ruff/src/rules/pyflakes/format.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ pub(crate) fn error_to_string(err: &FormatParseError) -> String {
1111
FormatParseError::InvalidCharacterAfterRightBracket => {
1212
"Only '.' or '[' may follow ']' in format field specifier"
1313
}
14-
FormatParseError::InvalidFormatSpecifier => "Max string recursion exceeded",
14+
FormatParseError::PlaceholderRecursionExceeded => {
15+
"Max format placeholder recursion exceeded"
16+
}
1517
FormatParseError::MissingStartBracket => "Single '}' encountered in format string",
1618
FormatParseError::MissingRightBracket => "Expected '}' before end of string",
1719
FormatParseError::UnmatchedBracket => "Single '{' encountered in format string",

crates/ruff/src/rules/pyflakes/snapshots/ruff__rules__pyflakes__tests__F521_F521.py.snap

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ F521.py:3:1: F521 `.format` call has invalid format string: Expected '}' before
2828
5 | "{:{:{}}}".format(1, 2, 3)
2929
|
3030

31-
F521.py:5:1: F521 `.format` call has invalid format string: Max string recursion exceeded
31+
F521.py:5:1: F521 `.format` call has invalid format string: Max format placeholder recursion exceeded
3232
|
3333
3 | "{foo[}".format(foo=1)
3434
4 | # too much string recursion (placeholder-in-placeholder)

crates/ruff/src/rules/pylint/rules/bad_string_format_character.rs

+33-10
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,39 @@ pub(crate) fn call(checker: &mut Checker, string: &str, range: TextRange) {
4949
continue;
5050
};
5151

52-
if let Err(FormatSpecError::InvalidFormatType) = FormatSpec::parse(format_spec) {
53-
checker.diagnostics.push(Diagnostic::new(
54-
BadStringFormatCharacter {
55-
// The format type character is always the last one.
56-
// More info in the official spec:
57-
// https://docs.python.org/3/library/string.html#format-specification-mini-language
58-
format_char: format_spec.chars().last().unwrap(),
59-
},
60-
range,
61-
));
52+
match FormatSpec::parse(format_spec) {
53+
Err(FormatSpecError::InvalidFormatType) => {
54+
checker.diagnostics.push(Diagnostic::new(
55+
BadStringFormatCharacter {
56+
// The format type character is always the last one.
57+
// More info in the official spec:
58+
// https://docs.python.org/3/library/string.html#format-specification-mini-language
59+
format_char: format_spec.chars().last().unwrap(),
60+
},
61+
range,
62+
));
63+
}
64+
Err(_) => {}
65+
Ok(format_spec) => {
66+
for replacement in format_spec.replacements() {
67+
let FormatPart::Field { format_spec, .. } = replacement else {
68+
continue;
69+
};
70+
if let Err(FormatSpecError::InvalidFormatType) =
71+
FormatSpec::parse(format_spec)
72+
{
73+
checker.diagnostics.push(Diagnostic::new(
74+
BadStringFormatCharacter {
75+
// The format type character is always the last one.
76+
// More info in the official spec:
77+
// https://docs.python.org/3/library/string.html#format-specification-mini-language
78+
format_char: format_spec.chars().last().unwrap(),
79+
},
80+
range,
81+
));
82+
}
83+
}
84+
}
6285
}
6386
}
6487
}

crates/ruff/src/rules/pylint/snapshots/ruff__rules__pylint__tests__PLE1300_bad_string_format_character.py.snap

+11-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,17 @@ bad_string_format_character.py:15:1: PLE1300 Unsupported format character 'y'
4848
15 | "{:s} {:y}".format("hello", "world") # [bad-format-character]
4949
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLE1300
5050
16 |
51-
17 | "{:*^30s}".format("centered")
51+
17 | "{:*^30s}".format("centered") # OK
52+
|
53+
54+
bad_string_format_character.py:20:1: PLE1300 Unsupported format character 'y'
55+
|
56+
18 | "{:{s}}".format("hello", s="s") # OK (nested replacement value not checked)
57+
19 |
58+
20 | "{:{s:y}}".format("hello", s="s") # [bad-format-character] (nested replacement format spec checked)
59+
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ PLE1300
60+
21 |
61+
22 | ## f-strings
5262
|
5363

5464

0 commit comments

Comments
 (0)