mirror of
https://github.com/astral-sh/ruff.git
synced 2025-10-18 06:18:42 +00:00
Fix syntax error false positives for escapes and quotes in f-strings (#20867)
Summary -- Fixes #20844 by refining the unsupported syntax error check for [PEP 701] f-strings before Python 3.12 to allow backslash escapes and escaped outer quotes in the format spec part of f-strings. These are only disallowed within the f-string expression part on earlier versions. Using the examples from the PR: ```pycon >>> f"{1:\x64}" '1' >>> f"{1:\"d\"}" Traceback (most recent call last): File "<stdin>", line 1, in <module> ValueError: Invalid format specifier '"d"' for object of type 'int' ``` Note that the second case is a runtime error, but this is actually avoidable if you override `__format__`, so despite being pretty weird, this could actually be a valid use case. ```pycon >>> class C: ... def __format__(*args, **kwargs): return "<C>" ... >>> f"{C():\"d\"}" '<C>' ``` At first I thought narrowing the range we check to exclude the format spec would only work for escapes, but it turns out that cases like `f"{1:""}"` are already covered by an existing `ParseError`, so we can just narrow the range of both our escape and quote checks. Our comment check also seems to be working correctly because it's based on the actual tokens. A case like [this](https://play.ruff.rs/9f1c2ff2-cd8e-4ad7-9f40-56c0a524209f): ```python f"""{1:# }""" ``` doesn't include a comment token, instead the `#` is part of an `InterpolatedStringLiteralElement`. Test Plan -- New inline parser tests [PEP 701]: https://peps.python.org/pep-0701/
This commit is contained in:
parent
8817ea5c84
commit
8b9ab48ac6
9 changed files with 317 additions and 62 deletions
|
@ -0,0 +1,2 @@
|
|||
# parse_options: {"target-version": "3.12"}
|
||||
f"{1:""}" # this is a ParseError on all versions
|
|
@ -0,0 +1,2 @@
|
|||
# parse_options: {"target-version": "3.11"}
|
||||
f"{1:''}" # but this is okay on all versions
|
|
@ -5,3 +5,5 @@ f"""{f'''{f'{"# not a comment"}'}'''}"""
|
|||
f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression"""
|
||||
f"escape outside of \t {expr}\n"
|
||||
f"test\"abcd"
|
||||
f"{1:\x64}" # escapes are valid in the format spec
|
||||
f"{1:\"d\"}" # this also means that escaped outer quotes are valid
|
||||
|
|
|
@ -1571,6 +1571,8 @@ impl<'src> Parser<'src> {
|
|||
// f"""{f'''# before expression {f'# aro{f"#{1+1}#"}und #'}'''} # after expression"""
|
||||
// f"escape outside of \t {expr}\n"
|
||||
// f"test\"abcd"
|
||||
// f"{1:\x64}" # escapes are valid in the format spec
|
||||
// f"{1:\"d\"}" # this also means that escaped outer quotes are valid
|
||||
|
||||
// test_err pep701_f_string_py311
|
||||
// # parse_options: {"target-version": "3.11"}
|
||||
|
@ -1586,6 +1588,13 @@ impl<'src> Parser<'src> {
|
|||
// f"""{f"""{x}"""}""" # mark the whole triple quote
|
||||
// f"{'\n'.join(['\t', '\v', '\r'])}" # multiple escape sequences, multiple errors
|
||||
|
||||
// test_err nested_quote_in_format_spec_py312
|
||||
// # parse_options: {"target-version": "3.12"}
|
||||
// f"{1:""}" # this is a ParseError on all versions
|
||||
|
||||
// test_ok non_nested_quote_in_format_spec_py311
|
||||
// # parse_options: {"target-version": "3.11"}
|
||||
// f"{1:''}" # but this is okay on all versions
|
||||
let range = self.node_range(start);
|
||||
|
||||
if !self.options.target_version.supports_pep_701()
|
||||
|
@ -1594,22 +1603,29 @@ impl<'src> Parser<'src> {
|
|||
let quote_bytes = flags.quote_str().as_bytes();
|
||||
let quote_len = flags.quote_len();
|
||||
for expr in elements.interpolations() {
|
||||
for slash_position in memchr::memchr_iter(b'\\', self.source[expr.range].as_bytes())
|
||||
{
|
||||
// We need to check the whole expression range, including any leading or trailing
|
||||
// debug text, but exclude the format spec, where escapes and escaped, reused quotes
|
||||
// are allowed.
|
||||
let range = expr
|
||||
.format_spec
|
||||
.as_ref()
|
||||
.map(|format_spec| TextRange::new(expr.start(), format_spec.start()))
|
||||
.unwrap_or(expr.range);
|
||||
for slash_position in memchr::memchr_iter(b'\\', self.source[range].as_bytes()) {
|
||||
let slash_position = TextSize::try_from(slash_position).unwrap();
|
||||
self.add_unsupported_syntax_error(
|
||||
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::Backslash),
|
||||
TextRange::at(expr.range.start() + slash_position, '\\'.text_len()),
|
||||
TextRange::at(range.start() + slash_position, '\\'.text_len()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(quote_position) =
|
||||
memchr::memmem::find(self.source[expr.range].as_bytes(), quote_bytes)
|
||||
memchr::memmem::find(self.source[range].as_bytes(), quote_bytes)
|
||||
{
|
||||
let quote_position = TextSize::try_from(quote_position).unwrap();
|
||||
self.add_unsupported_syntax_error(
|
||||
UnsupportedSyntaxErrorKind::Pep701FString(FStringKind::NestedQuote),
|
||||
TextRange::at(expr.range.start() + quote_position, quote_len),
|
||||
TextRange::at(range.start() + quote_position, quote_len),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
---
|
||||
source: crates/ruff_python_parser/tests/fixtures.rs
|
||||
input_file: crates/ruff_python_parser/resources/inline/err/nested_quote_in_format_spec_py312.py
|
||||
---
|
||||
## AST
|
||||
|
||||
```
|
||||
Module(
|
||||
ModModule {
|
||||
node_index: NodeIndex(None),
|
||||
range: 0..94,
|
||||
body: [
|
||||
Expr(
|
||||
StmtExpr {
|
||||
node_index: NodeIndex(None),
|
||||
range: 44..53,
|
||||
value: FString(
|
||||
ExprFString {
|
||||
node_index: NodeIndex(None),
|
||||
range: 44..53,
|
||||
value: FStringValue {
|
||||
inner: Concatenated(
|
||||
[
|
||||
FString(
|
||||
FString {
|
||||
range: 44..50,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Interpolation(
|
||||
InterpolatedElement {
|
||||
range: 46..49,
|
||||
node_index: NodeIndex(None),
|
||||
expression: NumberLiteral(
|
||||
ExprNumberLiteral {
|
||||
node_index: NodeIndex(None),
|
||||
range: 47..48,
|
||||
value: Int(
|
||||
1,
|
||||
),
|
||||
},
|
||||
),
|
||||
debug_text: None,
|
||||
conversion: None,
|
||||
format_spec: Some(
|
||||
InterpolatedStringFormatSpec {
|
||||
range: 49..49,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
flags: FStringFlags {
|
||||
quote_style: Double,
|
||||
prefix: Regular,
|
||||
triple_quoted: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
Literal(
|
||||
StringLiteral {
|
||||
range: 50..53,
|
||||
node_index: NodeIndex(None),
|
||||
value: "}",
|
||||
flags: StringLiteralFlags {
|
||||
quote_style: Double,
|
||||
prefix: Empty,
|
||||
triple_quoted: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
||||
## Errors
|
||||
|
||||
|
|
||||
1 | # parse_options: {"target-version": "3.12"}
|
||||
2 | f"{1:""}" # this is a ParseError on all versions
|
||||
| ^ Syntax Error: f-string: expecting '}'
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
---
|
||||
source: crates/ruff_python_parser/tests/fixtures.rs
|
||||
input_file: crates/ruff_python_parser/resources/inline/ok/non_nested_quote_in_format_spec_py311.py
|
||||
---
|
||||
## AST
|
||||
|
||||
```
|
||||
Module(
|
||||
ModModule {
|
||||
node_index: NodeIndex(None),
|
||||
range: 0..90,
|
||||
body: [
|
||||
Expr(
|
||||
StmtExpr {
|
||||
node_index: NodeIndex(None),
|
||||
range: 44..53,
|
||||
value: FString(
|
||||
ExprFString {
|
||||
node_index: NodeIndex(None),
|
||||
range: 44..53,
|
||||
value: FStringValue {
|
||||
inner: Single(
|
||||
FString(
|
||||
FString {
|
||||
range: 44..53,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Interpolation(
|
||||
InterpolatedElement {
|
||||
range: 46..52,
|
||||
node_index: NodeIndex(None),
|
||||
expression: NumberLiteral(
|
||||
ExprNumberLiteral {
|
||||
node_index: NodeIndex(None),
|
||||
range: 47..48,
|
||||
value: Int(
|
||||
1,
|
||||
),
|
||||
},
|
||||
),
|
||||
debug_text: None,
|
||||
conversion: None,
|
||||
format_spec: Some(
|
||||
InterpolatedStringFormatSpec {
|
||||
range: 49..51,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Literal(
|
||||
InterpolatedStringLiteralElement {
|
||||
range: 49..51,
|
||||
node_index: NodeIndex(None),
|
||||
value: "''",
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
flags: FStringFlags {
|
||||
quote_style: Double,
|
||||
prefix: Regular,
|
||||
triple_quoted: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
```
|
|
@ -8,7 +8,7 @@ input_file: crates/ruff_python_parser/resources/inline/ok/pep701_f_string_py311.
|
|||
Module(
|
||||
ModModule {
|
||||
node_index: NodeIndex(None),
|
||||
range: 0..278,
|
||||
range: 0..398,
|
||||
body: [
|
||||
Expr(
|
||||
StmtExpr {
|
||||
|
@ -604,6 +604,128 @@ Module(
|
|||
),
|
||||
},
|
||||
),
|
||||
Expr(
|
||||
StmtExpr {
|
||||
node_index: NodeIndex(None),
|
||||
range: 278..289,
|
||||
value: FString(
|
||||
ExprFString {
|
||||
node_index: NodeIndex(None),
|
||||
range: 278..289,
|
||||
value: FStringValue {
|
||||
inner: Single(
|
||||
FString(
|
||||
FString {
|
||||
range: 278..289,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Interpolation(
|
||||
InterpolatedElement {
|
||||
range: 280..288,
|
||||
node_index: NodeIndex(None),
|
||||
expression: NumberLiteral(
|
||||
ExprNumberLiteral {
|
||||
node_index: NodeIndex(None),
|
||||
range: 281..282,
|
||||
value: Int(
|
||||
1,
|
||||
),
|
||||
},
|
||||
),
|
||||
debug_text: None,
|
||||
conversion: None,
|
||||
format_spec: Some(
|
||||
InterpolatedStringFormatSpec {
|
||||
range: 283..287,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Literal(
|
||||
InterpolatedStringLiteralElement {
|
||||
range: 283..287,
|
||||
node_index: NodeIndex(None),
|
||||
value: "d",
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
flags: FStringFlags {
|
||||
quote_style: Double,
|
||||
prefix: Regular,
|
||||
triple_quoted: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
Expr(
|
||||
StmtExpr {
|
||||
node_index: NodeIndex(None),
|
||||
range: 330..342,
|
||||
value: FString(
|
||||
ExprFString {
|
||||
node_index: NodeIndex(None),
|
||||
range: 330..342,
|
||||
value: FStringValue {
|
||||
inner: Single(
|
||||
FString(
|
||||
FString {
|
||||
range: 330..342,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Interpolation(
|
||||
InterpolatedElement {
|
||||
range: 332..341,
|
||||
node_index: NodeIndex(None),
|
||||
expression: NumberLiteral(
|
||||
ExprNumberLiteral {
|
||||
node_index: NodeIndex(None),
|
||||
range: 333..334,
|
||||
value: Int(
|
||||
1,
|
||||
),
|
||||
},
|
||||
),
|
||||
debug_text: None,
|
||||
conversion: None,
|
||||
format_spec: Some(
|
||||
InterpolatedStringFormatSpec {
|
||||
range: 335..340,
|
||||
node_index: NodeIndex(None),
|
||||
elements: [
|
||||
Literal(
|
||||
InterpolatedStringLiteralElement {
|
||||
range: 335..340,
|
||||
node_index: NodeIndex(None),
|
||||
value: "\"d\"",
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
flags: FStringFlags {
|
||||
quote_style: Double,
|
||||
prefix: Regular,
|
||||
triple_quoted: false,
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue