Add support for PEP 701 (#7376)

## Summary

This PR adds support for PEP 701 in Ruff. This is a rollup PR of all the
other individual PRs. The separate PRs were created for logic separation
and code reviews. Refer to each pull request for a detail description on
the change.

Refer to the PR description for the list of pull requests within this PR.

## Test Plan

### Formatter ecosystem checks

Explanation for the change in ecosystem check:
https://github.com/astral-sh/ruff/pull/7597#issue-1908878183

#### `main`

```
| project      | similarity index  | total files       | changed files     |
|--------------|------------------:|------------------:|------------------:|
| cpython      |           0.76083 |              1789 |              1631 |
| django       |           0.99983 |              2760 |                36 |
| transformers |           0.99963 |              2587 |               319 |
| twine        |           1.00000 |                33 |                 0 |
| typeshed     |           0.99983 |              3496 |                18 |
| warehouse    |           0.99967 |               648 |                15 |
| zulip        |           0.99972 |              1437 |                21 |
```

#### `dhruv/pep-701`

```
| project      | similarity index  | total files       | changed files     |
|--------------|------------------:|------------------:|------------------:|
| cpython      |           0.76051 |              1789 |              1632 |
| django       |           0.99983 |              2760 |                36 |
| transformers |           0.99963 |              2587 |               319 |
| twine        |           1.00000 |                33 |                 0 |
| typeshed     |           0.99983 |              3496 |                18 |
| warehouse    |           0.99967 |               648 |                15 |
| zulip        |           0.99972 |              1437 |                21 |
```
This commit is contained in:
Dhruv Manilawala 2023-09-29 08:25:39 +05:30 committed by GitHub
parent 78b8741352
commit e62e245c61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
115 changed files with 44780 additions and 31370 deletions

1
Cargo.lock generated
View file

@ -2352,6 +2352,7 @@ name = "ruff_python_parser"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bitflags 2.4.0",
"insta", "insta",
"is-macro", "is-macro",
"itertools 0.11.0", "itertools 0.11.0",

View file

@ -65,7 +65,7 @@ fn benchmark_formatter(criterion: &mut Criterion) {
let comment_ranges = comment_ranges.finish(); let comment_ranges = comment_ranges.finish();
// Parse the AST. // Parse the AST.
let module = parse_tokens(tokens, Mode::Module, "<filename>") let module = parse_tokens(tokens, case.code(), Mode::Module, "<filename>")
.expect("Input to be a valid python program"); .expect("Input to be a valid python program");
b.iter(|| { b.iter(|| {

View file

@ -59,3 +59,23 @@ _ = "abc" + "def" + foo
_ = foo + bar + "abc" _ = foo + bar + "abc"
_ = "abc" + foo + bar _ = "abc" + foo + bar
_ = foo + "abc" + bar _ = foo + "abc" + bar
# Multiple strings nested inside a f-string
_ = f"a {'b' 'c' 'd'} e"
_ = f"""abc {"def" "ghi"} jkl"""
_ = f"""abc {
"def"
"ghi"
} jkl"""
# Nested f-strings
_ = "a" f"b {f"c" f"d"} e" "f"
_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
_ = f"b {f"abc" \
f"def"} g"
# Explicitly concatenated nested f-strings
_ = f"a {f"first"
+ f"second"} d"
_ = f"a {f"first {f"middle"}"
+ f"second"} d"

View file

@ -9,3 +9,33 @@ this_should_raise = (
'This is a' 'This is a'
'\'string\'' '\'string\''
) )
# Same as above, but with f-strings
f'This is a \'string\'' # Q003
f'This is \\ a \\\'string\'' # Q003
f'"This" is a \'string\''
f"This is a 'string'"
f"\"This\" is a 'string'"
fr'This is a \'string\''
fR'This is a \'string\''
foo = (
f'This is a'
f'\'string\'' # Q003
)
# Nested f-strings (Python 3.12+)
#
# The first one is interesting because the fix for it is valid pre 3.12:
#
# f"'foo' {'nested'}"
#
# but as the actual string itself is invalid pre 3.12, we don't catch it.
f'\'foo\' {'nested'}' # Q003
f'\'foo\' {f'nested'}' # Q003
f'\'foo\' {f'\'nested\''} \'\'' # Q003
f'normal {f'nested'} normal'
f'\'normal\' {f'nested'} normal' # Q003
f'\'normal\' {f'nested'} "double quotes"'
f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l

View file

@ -8,3 +8,32 @@ this_should_raise = (
"This is a" "This is a"
"\"string\"" "\"string\""
) )
# Same as above, but with f-strings
f"This is a \"string\""
f"'This' is a \"string\""
f'This is a "string"'
f'\'This\' is a "string"'
fr"This is a \"string\""
fR"This is a \"string\""
foo = (
f"This is a"
f"\"string\""
)
# Nested f-strings (Python 3.12+)
#
# The first one is interesting because the fix for it is valid pre 3.12:
#
# f'"foo" {"nested"}'
#
# but as the actual string itself is invalid pre 3.12, we don't catch it.
f"\"foo\" {"foo"}" # Q003
f"\"foo\" {f"foo"}" # Q003
f"\"foo\" {f"\"foo\""} \"\"" # Q003
f"normal {f"nested"} normal"
f"\"normal\" {f"nested"} normal" # Q003
f"\"normal\" {f"nested"} 'single quotes'"
f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003

View file

@ -84,3 +84,8 @@ spam[ ~ham]
x = [ # x = [ #
'some value', 'some value',
] ]
# F-strings
f"{ {'a': 1} }"
f"{[ { {'a': 1} } ]}"
f"normal { {f"{ { [1, 2] } }" } } normal"

View file

@ -29,5 +29,16 @@ mdtypes_template = {
'tag_smalldata':[('byte_count_mdtype', 'u4'), ('data', 'S4')], 'tag_smalldata':[('byte_count_mdtype', 'u4'), ('data', 'S4')],
} }
# E231
f"{(a,b)}"
# Okay because it's hard to differentiate between the usages of a colon in a f-string
f"{a:=1}"
f"{ {'a':1} }"
f"{a:.3f}"
f"{(a:=1)}"
f"{(lambda x:x)}"
f"normal{f"{a:.3f}"}normal"
#: Okay #: Okay
a = (1, a = (1,

View file

@ -48,3 +48,9 @@ def add(a: int=0, b: int =0, c: int= 0) -> int:
#: Okay #: Okay
def add(a: int = _default(name='f')): def add(a: int = _default(name='f')):
return a return a
# F-strings
f"{a=}"
f"{a:=1}"
f"{foo(a=1)}"
f"normal {f"{a=}"} normal"

View file

@ -152,3 +152,11 @@ x = [
multiline string with tab in it, different lines multiline string with tab in it, different lines
''' '''
" single line string with tab in it" " single line string with tab in it"
f"test{
tab_indented_should_be_flagged
} <- this tab is fine"
f"""test{
tab_indented_should_be_flagged
} <- this tab is fine"""

View file

@ -0,0 +1,54 @@
# Same as `W605_0.py` but using f-strings instead.
#: W605:1:10
regex = f'\.png$'
#: W605:2:1
regex = f'''
\.png$
'''
#: W605:2:6
f(
f'\_'
)
#: W605:4:6
f"""
multi-line
literal
with \_ somewhere
in the middle
"""
#: W605:1:38
value = f'new line\nand invalid escape \_ here'
#: Okay
regex = fr'\.png$'
regex = f'\\.png$'
regex = fr'''
\.png$
'''
regex = fr'''
\\.png$
'''
s = f'\\'
regex = f'\w' # noqa
regex = f'''
\w
''' # noqa
regex = f'\\\_'
value = f'\{{1}}'
value = f'\{1}'
value = f'{1:\}'
value = f"{f"\{1}"}"
value = rf"{f"\{1}"}"
# Okay
value = rf'\{{1}}'
value = rf'\{1}'
value = rf'{1:\}'
value = f"{rf"\{1}"}"

View file

@ -40,7 +40,5 @@ f"{{{{x}}}}"
""f"" ""f""
''f"" ''f""
(""f""r"") (""f""r"")
f"{v:{f"0.2f"}}"
# To be fixed f"\{{x}}"
# Error: f-string: single '}' is not allowed at line 41 column 8
# f"\{{x}}"

View file

@ -29,3 +29,19 @@ x = "βα Bαd"
# consisting of a single ambiguous character, while the second character is a "word # consisting of a single ambiguous character, while the second character is a "word
# boundary" (whitespace) that it itself ambiguous. # boundary" (whitespace) that it itself ambiguous.
x = "Р усский" x = "Р усский"
# Same test cases as above but using f-strings instead:
x = f"𝐁ad string"
x = f""
x = f"Русский"
x = f"βα Bαd"
x = f"Р усский"
# Nested f-strings
x = f"𝐁ad string {f" {f"Р усский"}"}"
# Comments inside f-strings
x = f"string { # And here's a comment with an unusual parenthesis:
# And here's a comment with a greek alpha:
foo # And here's a comment with an unusual punctuation mark:
}"

View file

@ -966,9 +966,9 @@ pub(crate) fn expression(expr: &Expr, checker: &mut Checker) {
pylint::rules::await_outside_async(checker, expr); pylint::rules::await_outside_async(checker, expr);
} }
} }
Expr::FString(ast::ExprFString { values, .. }) => { Expr::FString(fstring @ ast::ExprFString { values, .. }) => {
if checker.enabled(Rule::FStringMissingPlaceholders) { if checker.enabled(Rule::FStringMissingPlaceholders) {
pyflakes::rules::f_string_missing_placeholders(expr, values, checker); pyflakes::rules::f_string_missing_placeholders(fstring, checker);
} }
if checker.enabled(Rule::HardcodedSQLExpression) { if checker.enabled(Rule::HardcodedSQLExpression) {
flake8_bandit::rules::hardcoded_sql_expression(checker, expr); flake8_bandit::rules::hardcoded_sql_expression(checker, expr);

View file

@ -183,7 +183,7 @@ impl<'a> Checker<'a> {
// Find the quote character used to start the containing f-string. // Find the quote character used to start the containing f-string.
let expr = self.semantic.current_expression()?; let expr = self.semantic.current_expression()?;
let string_range = self.indexer.f_string_range(expr.start())?; let string_range = self.indexer.fstring_ranges().innermost(expr.start())?;
let trailing_quote = trailing_quote(self.locator.slice(string_range))?; let trailing_quote = trailing_quote(self.locator.slice(string_range))?;
// Invert the quote character, if it's a single quote. // Invert the quote character, if it's a single quote.

View file

@ -45,23 +45,25 @@ pub(crate) fn check_tokens(
let mut state_machine = StateMachine::default(); let mut state_machine = StateMachine::default();
for &(ref tok, range) in tokens.iter().flatten() { for &(ref tok, range) in tokens.iter().flatten() {
let is_docstring = state_machine.consume(tok); let is_docstring = state_machine.consume(tok);
if matches!(tok, Tok::String { .. } | Tok::Comment(_)) { let context = match tok {
ruff::rules::ambiguous_unicode_character( Tok::String { .. } => {
&mut diagnostics, if is_docstring {
locator, Context::Docstring
range,
if tok.is_string() {
if is_docstring {
Context::Docstring
} else {
Context::String
}
} else { } else {
Context::Comment Context::String
}, }
settings, }
); Tok::FStringMiddle { .. } => Context::String,
} Tok::Comment(_) => Context::Comment,
_ => continue,
};
ruff::rules::ambiguous_unicode_character(
&mut diagnostics,
locator,
range,
context,
settings,
);
} }
} }
@ -75,14 +77,14 @@ pub(crate) fn check_tokens(
if settings.rules.enabled(Rule::InvalidEscapeSequence) { if settings.rules.enabled(Rule::InvalidEscapeSequence) {
for (tok, range) in tokens.iter().flatten() { for (tok, range) in tokens.iter().flatten() {
if tok.is_string() { pycodestyle::rules::invalid_escape_sequence(
pycodestyle::rules::invalid_escape_sequence( &mut diagnostics,
&mut diagnostics, locator,
locator, indexer,
*range, tok,
settings.rules.should_fix(Rule::InvalidEscapeSequence), *range,
); settings.rules.should_fix(Rule::InvalidEscapeSequence),
} );
} }
} }
@ -98,9 +100,7 @@ pub(crate) fn check_tokens(
Rule::InvalidCharacterZeroWidthSpace, Rule::InvalidCharacterZeroWidthSpace,
]) { ]) {
for (tok, range) in tokens.iter().flatten() { for (tok, range) in tokens.iter().flatten() {
if tok.is_string() { pylint::rules::invalid_string_characters(&mut diagnostics, tok, *range, locator);
pylint::rules::invalid_string_characters(&mut diagnostics, *range, locator);
}
} }
} }
@ -118,13 +118,16 @@ pub(crate) fn check_tokens(
); );
} }
if settings.rules.enabled(Rule::AvoidableEscapedQuote) && settings.flake8_quotes.avoid_escape {
flake8_quotes::rules::avoidable_escaped_quote(&mut diagnostics, tokens, locator, settings);
}
if settings.rules.any_enabled(&[ if settings.rules.any_enabled(&[
Rule::BadQuotesInlineString, Rule::BadQuotesInlineString,
Rule::BadQuotesMultilineString, Rule::BadQuotesMultilineString,
Rule::BadQuotesDocstring, Rule::BadQuotesDocstring,
Rule::AvoidableEscapedQuote,
]) { ]) {
flake8_quotes::rules::from_tokens(&mut diagnostics, tokens, locator, settings); flake8_quotes::rules::check_string_quotes(&mut diagnostics, tokens, locator, settings);
} }
if settings.rules.any_enabled(&[ if settings.rules.any_enabled(&[
@ -136,6 +139,7 @@ pub(crate) fn check_tokens(
tokens, tokens,
&settings.flake8_implicit_str_concat, &settings.flake8_implicit_str_concat,
locator, locator,
indexer,
); );
} }

View file

@ -1,5 +1,6 @@
//! Extract `# noqa`, `# isort: skip`, and `# TODO` directives from tokenized source. //! Extract `# noqa`, `# isort: skip`, and `# TODO` directives from tokenized source.
use std::iter::Peekable;
use std::str::FromStr; use std::str::FromStr;
use bitflags::bitflags; use bitflags::bitflags;
@ -85,6 +86,39 @@ pub fn extract_directives(
} }
} }
struct SortedMergeIter<L, R, Item>
where
L: Iterator<Item = Item>,
R: Iterator<Item = Item>,
{
left: Peekable<L>,
right: Peekable<R>,
}
impl<L, R, Item> Iterator for SortedMergeIter<L, R, Item>
where
L: Iterator<Item = Item>,
R: Iterator<Item = Item>,
Item: Ranged,
{
type Item = Item;
fn next(&mut self) -> Option<Self::Item> {
match (self.left.peek(), self.right.peek()) {
(Some(left), Some(right)) => {
if left.start() <= right.start() {
Some(self.left.next().unwrap())
} else {
Some(self.right.next().unwrap())
}
}
(Some(_), None) => Some(self.left.next().unwrap()),
(None, Some(_)) => Some(self.right.next().unwrap()),
(None, None) => None,
}
}
}
/// Extract a mapping from logical line to noqa line. /// Extract a mapping from logical line to noqa line.
fn extract_noqa_line_for(lxr: &[LexResult], locator: &Locator, indexer: &Indexer) -> NoqaMapping { fn extract_noqa_line_for(lxr: &[LexResult], locator: &Locator, indexer: &Indexer) -> NoqaMapping {
let mut string_mappings = Vec::new(); let mut string_mappings = Vec::new();
@ -113,6 +147,29 @@ fn extract_noqa_line_for(lxr: &[LexResult], locator: &Locator, indexer: &Indexer
} }
} }
// The capacity allocated here might be more than we need if there are
// nested f-strings.
let mut fstring_mappings = Vec::with_capacity(indexer.fstring_ranges().len());
// For nested f-strings, we expect `noqa` directives on the last line of the
// outermost f-string. The last f-string range will be used to skip over
// the inner f-strings.
let mut last_fstring_range: TextRange = TextRange::default();
for fstring_range in indexer.fstring_ranges().values() {
if !locator.contains_line_break(*fstring_range) {
continue;
}
if last_fstring_range.contains_range(*fstring_range) {
continue;
}
let new_range = TextRange::new(
locator.line_start(fstring_range.start()),
fstring_range.end(),
);
fstring_mappings.push(new_range);
last_fstring_range = new_range;
}
let mut continuation_mappings = Vec::new(); let mut continuation_mappings = Vec::new();
// For continuations, we expect `noqa` directives on the last line of the // For continuations, we expect `noqa` directives on the last line of the
@ -137,27 +194,20 @@ fn extract_noqa_line_for(lxr: &[LexResult], locator: &Locator, indexer: &Indexer
} }
// Merge the mappings in sorted order // Merge the mappings in sorted order
let mut mappings = let mut mappings = NoqaMapping::with_capacity(
NoqaMapping::with_capacity(continuation_mappings.len() + string_mappings.len()); continuation_mappings.len() + string_mappings.len() + fstring_mappings.len(),
);
let mut continuation_mappings = continuation_mappings.into_iter().peekable(); let string_mappings = SortedMergeIter {
let mut string_mappings = string_mappings.into_iter().peekable(); left: fstring_mappings.into_iter().peekable(),
right: string_mappings.into_iter().peekable(),
};
let all_mappings = SortedMergeIter {
left: string_mappings.peekable(),
right: continuation_mappings.into_iter().peekable(),
};
while let (Some(continuation), Some(string)) = for mapping in all_mappings {
(continuation_mappings.peek(), string_mappings.peek())
{
if continuation.start() <= string.start() {
mappings.push_mapping(continuation_mappings.next().unwrap());
} else {
mappings.push_mapping(string_mappings.next().unwrap());
}
}
for mapping in continuation_mappings {
mappings.push_mapping(mapping);
}
for mapping in string_mappings {
mappings.push_mapping(mapping); mappings.push_mapping(mapping);
} }
@ -429,6 +479,67 @@ ghi
NoqaMapping::from_iter([TextRange::new(TextSize::from(6), TextSize::from(28))]) NoqaMapping::from_iter([TextRange::new(TextSize::from(6), TextSize::from(28))])
); );
let contents = "x = f'abc {
a
*
b
}'
y = 2
";
assert_eq!(
noqa_mappings(contents),
NoqaMapping::from_iter([TextRange::new(TextSize::from(0), TextSize::from(32))])
);
let contents = "x = f'''abc
def
ghi
'''
y = 2
z = x + 1";
assert_eq!(
noqa_mappings(contents),
NoqaMapping::from_iter([TextRange::new(TextSize::from(0), TextSize::from(23))])
);
let contents = "x = 1
y = f'''abc
def
ghi
'''
z = 2";
assert_eq!(
noqa_mappings(contents),
NoqaMapping::from_iter([TextRange::new(TextSize::from(6), TextSize::from(29))])
);
let contents = "x = 1
y = f'''abc
def
ghi
'''";
assert_eq!(
noqa_mappings(contents),
NoqaMapping::from_iter([TextRange::new(TextSize::from(6), TextSize::from(29))])
);
let contents = "x = 1
y = f'''abc
def {f'''nested
fstring''' f'another nested'}
end'''
";
assert_eq!(
noqa_mappings(contents),
NoqaMapping::from_iter([TextRange::new(TextSize::from(6), TextSize::from(70))])
);
let contents = "x = 1
y = f'normal'
z = f'another but {f'nested but {f'still single line'} nested'}'
";
assert_eq!(noqa_mappings(contents), NoqaMapping::default());
let contents = r"x = \ let contents = r"x = \
1"; 1";
assert_eq!( assert_eq!(

View file

@ -143,6 +143,7 @@ pub fn check_path(
if use_ast || use_imports || use_doc_lines { if use_ast || use_imports || use_doc_lines {
match ruff_python_parser::parse_program_tokens( match ruff_python_parser::parse_program_tokens(
tokens, tokens,
source_kind.source_code(),
&path.to_string_lossy(), &path.to_string_lossy(),
source_type.is_ipynb(), source_type.is_ipynb(),
) { ) {

View file

@ -1,10 +1,12 @@
use itertools::Itertools; use itertools::Itertools;
use ruff_python_parser::lexer::LexResult; use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::Tok;
use ruff_text_size::{Ranged, TextRange}; use ruff_text_size::{Ranged, TextRange};
use ruff_diagnostics::{Diagnostic, Edit, Fix, FixKind, Violation}; use ruff_diagnostics::{Diagnostic, Edit, Fix, FixKind, Violation};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::str::{leading_quote, trailing_quote}; use ruff_python_ast::str::{leading_quote, trailing_quote};
use ruff_python_index::Indexer;
use ruff_source_file::Locator; use ruff_source_file::Locator;
use crate::rules::flake8_implicit_str_concat::settings::Settings; use crate::rules::flake8_implicit_str_concat::settings::Settings;
@ -94,6 +96,7 @@ pub(crate) fn implicit(
tokens: &[LexResult], tokens: &[LexResult],
settings: &Settings, settings: &Settings,
locator: &Locator, locator: &Locator,
indexer: &Indexer,
) { ) {
for ((a_tok, a_range), (b_tok, b_range)) in tokens for ((a_tok, a_range), (b_tok, b_range)) in tokens
.iter() .iter()
@ -103,24 +106,39 @@ pub(crate) fn implicit(
}) })
.tuple_windows() .tuple_windows()
{ {
if a_tok.is_string() && b_tok.is_string() { let (a_range, b_range) = match (a_tok, b_tok) {
if locator.contains_line_break(TextRange::new(a_range.end(), b_range.start())) { (Tok::String { .. }, Tok::String { .. }) => (*a_range, *b_range),
diagnostics.push(Diagnostic::new( (Tok::String { .. }, Tok::FStringStart) => (
MultiLineImplicitStringConcatenation, *a_range,
TextRange::new(a_range.start(), b_range.end()), indexer.fstring_ranges().innermost(b_range.start()).unwrap(),
)); ),
} else { (Tok::FStringEnd, Tok::String { .. }) => (
let mut diagnostic = Diagnostic::new( indexer.fstring_ranges().innermost(a_range.start()).unwrap(),
SingleLineImplicitStringConcatenation, *b_range,
TextRange::new(a_range.start(), b_range.end()), ),
); (Tok::FStringEnd, Tok::FStringStart) => (
indexer.fstring_ranges().innermost(a_range.start()).unwrap(),
indexer.fstring_ranges().innermost(b_range.start()).unwrap(),
),
_ => continue,
};
if let Some(fix) = concatenate_strings(*a_range, *b_range, locator) { if locator.contains_line_break(TextRange::new(a_range.end(), b_range.start())) {
diagnostic.set_fix(fix); diagnostics.push(Diagnostic::new(
} MultiLineImplicitStringConcatenation,
TextRange::new(a_range.start(), b_range.end()),
));
} else {
let mut diagnostic = Diagnostic::new(
SingleLineImplicitStringConcatenation,
TextRange::new(a_range.start(), b_range.end()),
);
diagnostics.push(diagnostic); if let Some(fix) = concatenate_strings(a_range, b_range, locator) {
}; diagnostic.set_fix(fix);
}
diagnostics.push(diagnostic);
}; };
} }
} }

View file

@ -153,4 +153,147 @@ ISC.py:52:5: ISC001 [*] Implicitly concatenated string literals on one line
54 54 | # Single-line explicit concatenation should be ignored. 54 54 | # Single-line explicit concatenation should be ignored.
55 55 | _ = "abc" + "def" + "ghi" 55 55 | _ = "abc" + "def" + "ghi"
ISC.py:64:10: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
| ^^^^^^^ ISC001
65 | _ = f"""abc {"def" "ghi"} jkl"""
66 | _ = f"""abc {
|
= help: Combine string literals
Fix
61 61 | _ = foo + "abc" + bar
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 |-_ = f"a {'b' 'c' 'd'} e"
64 |+_ = f"a {'bc' 'd'} e"
65 65 | _ = f"""abc {"def" "ghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
ISC.py:64:14: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
| ^^^^^^^ ISC001
65 | _ = f"""abc {"def" "ghi"} jkl"""
66 | _ = f"""abc {
|
= help: Combine string literals
Fix
61 61 | _ = foo + "abc" + bar
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 |-_ = f"a {'b' 'c' 'd'} e"
64 |+_ = f"a {'b' 'cd'} e"
65 65 | _ = f"""abc {"def" "ghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
ISC.py:65:14: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
65 | _ = f"""abc {"def" "ghi"} jkl"""
| ^^^^^^^^^^^ ISC001
66 | _ = f"""abc {
67 | "def"
|
= help: Combine string literals
Fix
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 64 | _ = f"a {'b' 'c' 'd'} e"
65 |-_ = f"""abc {"def" "ghi"} jkl"""
65 |+_ = f"""abc {"defghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
68 68 | "ghi"
ISC.py:72:5: ISC001 Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^^^^^^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
ISC.py:72:9: ISC001 Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^^^^^^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
ISC.py:72:14: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
Fix
69 69 | } jkl"""
70 70 |
71 71 | # Nested f-strings
72 |-_ = "a" f"b {f"c" f"d"} e" "f"
72 |+_ = "a" f"b {f"cd"} e" "f"
73 73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
ISC.py:73:10: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
| ^^^^^^^^^^^^^^^^^^^^^^^ ISC001
74 | _ = f"b {f"abc" \
75 | f"def"} g"
|
= help: Combine string literals
Fix
70 70 |
71 71 | # Nested f-strings
72 72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 |-_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
73 |+_ = f"b {f"cd {f"e" f"f"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
76 76 |
ISC.py:73:20: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
| ^^^^^^^^^ ISC001
74 | _ = f"b {f"abc" \
75 | f"def"} g"
|
= help: Combine string literals
Fix
70 70 |
71 71 | # Nested f-strings
72 72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 |-_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
73 |+_ = f"b {f"c" f"d {f"ef"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
76 76 |

View file

@ -13,4 +13,16 @@ ISC.py:5:5: ISC002 Implicitly concatenated string literals over multiple lines
8 | _ = ( 8 | _ = (
| |
ISC.py:74:10: ISC002 Implicitly concatenated string literals over multiple lines
|
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
| __________^
75 | | f"def"} g"
| |__________^ ISC002
76 |
77 | # Explicitly concatenated nested f-strings
|

View file

@ -31,4 +31,25 @@ ISC.py:19:3: ISC003 Explicitly concatenated string should be implicitly concaten
21 | ) 21 | )
| |
ISC.py:78:10: ISC003 Explicitly concatenated string should be implicitly concatenated
|
77 | # Explicitly concatenated nested f-strings
78 | _ = f"a {f"first"
| __________^
79 | | + f"second"} d"
| |_______________^ ISC003
80 | _ = f"a {f"first {f"middle"}"
81 | + f"second"} d"
|
ISC.py:80:10: ISC003 Explicitly concatenated string should be implicitly concatenated
|
78 | _ = f"a {f"first"
79 | + f"second"} d"
80 | _ = f"a {f"first {f"middle"}"
| __________^
81 | | + f"second"} d"
| |_______________^ ISC003
|

View file

@ -153,4 +153,147 @@ ISC.py:52:5: ISC001 [*] Implicitly concatenated string literals on one line
54 54 | # Single-line explicit concatenation should be ignored. 54 54 | # Single-line explicit concatenation should be ignored.
55 55 | _ = "abc" + "def" + "ghi" 55 55 | _ = "abc" + "def" + "ghi"
ISC.py:64:10: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
| ^^^^^^^ ISC001
65 | _ = f"""abc {"def" "ghi"} jkl"""
66 | _ = f"""abc {
|
= help: Combine string literals
Fix
61 61 | _ = foo + "abc" + bar
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 |-_ = f"a {'b' 'c' 'd'} e"
64 |+_ = f"a {'bc' 'd'} e"
65 65 | _ = f"""abc {"def" "ghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
ISC.py:64:14: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
| ^^^^^^^ ISC001
65 | _ = f"""abc {"def" "ghi"} jkl"""
66 | _ = f"""abc {
|
= help: Combine string literals
Fix
61 61 | _ = foo + "abc" + bar
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 |-_ = f"a {'b' 'c' 'd'} e"
64 |+_ = f"a {'b' 'cd'} e"
65 65 | _ = f"""abc {"def" "ghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
ISC.py:65:14: ISC001 [*] Implicitly concatenated string literals on one line
|
63 | # Multiple strings nested inside a f-string
64 | _ = f"a {'b' 'c' 'd'} e"
65 | _ = f"""abc {"def" "ghi"} jkl"""
| ^^^^^^^^^^^ ISC001
66 | _ = f"""abc {
67 | "def"
|
= help: Combine string literals
Fix
62 62 |
63 63 | # Multiple strings nested inside a f-string
64 64 | _ = f"a {'b' 'c' 'd'} e"
65 |-_ = f"""abc {"def" "ghi"} jkl"""
65 |+_ = f"""abc {"defghi"} jkl"""
66 66 | _ = f"""abc {
67 67 | "def"
68 68 | "ghi"
ISC.py:72:5: ISC001 Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^^^^^^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
ISC.py:72:9: ISC001 Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^^^^^^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
ISC.py:72:14: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
| ^^^^^^^^^ ISC001
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
|
= help: Combine string literals
Fix
69 69 | } jkl"""
70 70 |
71 71 | # Nested f-strings
72 |-_ = "a" f"b {f"c" f"d"} e" "f"
72 |+_ = "a" f"b {f"cd"} e" "f"
73 73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
ISC.py:73:10: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
| ^^^^^^^^^^^^^^^^^^^^^^^ ISC001
74 | _ = f"b {f"abc" \
75 | f"def"} g"
|
= help: Combine string literals
Fix
70 70 |
71 71 | # Nested f-strings
72 72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 |-_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
73 |+_ = f"b {f"cd {f"e" f"f"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
76 76 |
ISC.py:73:20: ISC001 [*] Implicitly concatenated string literals on one line
|
71 | # Nested f-strings
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
| ^^^^^^^^^ ISC001
74 | _ = f"b {f"abc" \
75 | f"def"} g"
|
= help: Combine string literals
Fix
70 70 |
71 71 | # Nested f-strings
72 72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 |-_ = f"b {f"c" f"d {f"e" f"f"} g"} h"
73 |+_ = f"b {f"c" f"d {f"ef"} g"} h"
74 74 | _ = f"b {f"abc" \
75 75 | f"def"} g"
76 76 |

View file

@ -43,4 +43,27 @@ ISC.py:34:3: ISC002 Implicitly concatenated string literals over multiple lines
36 | ) 36 | )
| |
ISC.py:67:5: ISC002 Implicitly concatenated string literals over multiple lines
|
65 | _ = f"""abc {"def" "ghi"} jkl"""
66 | _ = f"""abc {
67 | "def"
| _____^
68 | | "ghi"
| |_________^ ISC002
69 | } jkl"""
|
ISC.py:74:10: ISC002 Implicitly concatenated string literals over multiple lines
|
72 | _ = "a" f"b {f"c" f"d"} e" "f"
73 | _ = f"b {f"c" f"d {f"e" f"f"} g"} h"
74 | _ = f"b {f"abc" \
| __________^
75 | | f"def"} g"
| |__________^ ISC002
76 |
77 | # Explicitly concatenated nested f-strings
|

View file

@ -31,4 +31,25 @@ ISC.py:19:3: ISC003 Explicitly concatenated string should be implicitly concaten
21 | ) 21 | )
| |
ISC.py:78:10: ISC003 Explicitly concatenated string should be implicitly concatenated
|
77 | # Explicitly concatenated nested f-strings
78 | _ = f"a {f"first"
| __________^
79 | | + f"second"} d"
| |_______________^ ISC003
80 | _ = f"a {f"first {f"middle"}"
81 | + f"second"} d"
|
ISC.py:80:10: ISC003 Explicitly concatenated string should be implicitly concatenated
|
78 | _ = f"a {f"first"
79 | + f"second"} d"
80 | _ = f"a {f"first {f"middle"}"
| __________^
81 | | + f"second"} d"
| |_______________^ ISC003
|

View file

@ -11,6 +11,7 @@ mod tests {
use crate::assert_messages; use crate::assert_messages;
use crate::registry::Rule; use crate::registry::Rule;
use crate::settings::types::PythonVersion;
use crate::settings::LinterSettings; use crate::settings::LinterSettings;
use crate::test::test_path; use crate::test::test_path;
@ -45,6 +46,44 @@ mod tests {
Ok(()) Ok(())
} }
#[test]
fn require_singles_over_doubles_escaped_py311() -> Result<()> {
let diagnostics = test_path(
Path::new("flake8_quotes/doubles_escaped.py"),
&LinterSettings {
flake8_quotes: super::settings::Settings {
inline_quotes: Quote::Single,
multiline_quotes: Quote::Single,
docstring_quotes: Quote::Single,
avoid_escape: true,
},
..LinterSettings::for_rule(Rule::AvoidableEscapedQuote)
.with_target_version(PythonVersion::Py311)
},
)?;
assert_messages!(diagnostics);
Ok(())
}
#[test]
fn require_doubles_over_singles_escaped_py311() -> Result<()> {
let diagnostics = test_path(
Path::new("flake8_quotes/singles_escaped.py"),
&LinterSettings {
flake8_quotes: super::settings::Settings {
inline_quotes: Quote::Double,
multiline_quotes: Quote::Double,
docstring_quotes: Quote::Double,
avoid_escape: true,
},
..LinterSettings::for_rule(Rule::AvoidableEscapedQuote)
.with_target_version(PythonVersion::Py311)
},
)?;
assert_messages!(diagnostics);
Ok(())
}
#[test_case(Path::new("singles.py"))] #[test_case(Path::new("singles.py"))]
#[test_case(Path::new("singles_escaped.py"))] #[test_case(Path::new("singles_escaped.py"))]
#[test_case(Path::new("singles_implicit.py"))] #[test_case(Path::new("singles_implicit.py"))]

View file

@ -0,0 +1,253 @@
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::str::{is_triple_quote, leading_quote};
use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::Tok;
use ruff_source_file::Locator;
use ruff_text_size::TextRange;
use crate::lex::docstring_detection::StateMachine;
use crate::registry::AsRule;
use crate::settings::LinterSettings;
/// ## What it does
/// Checks for strings that include escaped quotes, and suggests changing
/// the quote style to avoid the need to escape them.
///
/// ## Why is this bad?
/// It's preferable to avoid escaped quotes in strings. By changing the
/// outer quote style, you can avoid escaping inner quotes.
///
/// ## Example
/// ```python
/// foo = 'bar\'s'
/// ```
///
/// Use instead:
/// ```python
/// foo = "bar's"
/// ```
#[violation]
pub struct AvoidableEscapedQuote;
impl AlwaysFixableViolation for AvoidableEscapedQuote {
#[derive_message_formats]
fn message(&self) -> String {
format!("Change outer quotes to avoid escaping inner quotes")
}
fn fix_title(&self) -> String {
"Change outer quotes to avoid escaping inner quotes".to_string()
}
}
struct FStringContext {
/// Whether to check for escaped quotes in the f-string.
check_for_escaped_quote: bool,
/// The range of the f-string start token.
start_range: TextRange,
/// The ranges of the f-string middle tokens containing escaped quotes.
middle_ranges_with_escapes: Vec<TextRange>,
}
impl FStringContext {
fn new(check_for_escaped_quote: bool, fstring_start_range: TextRange) -> Self {
Self {
check_for_escaped_quote,
start_range: fstring_start_range,
middle_ranges_with_escapes: vec![],
}
}
/// Update the context to not check for escaped quotes, and clear any
/// existing reported ranges.
fn ignore_escaped_quotes(&mut self) {
self.check_for_escaped_quote = false;
self.middle_ranges_with_escapes.clear();
}
fn push_fstring_middle_range(&mut self, range: TextRange) {
self.middle_ranges_with_escapes.push(range);
}
}
/// Q003
pub(crate) fn avoidable_escaped_quote(
diagnostics: &mut Vec<Diagnostic>,
lxr: &[LexResult],
locator: &Locator,
settings: &LinterSettings,
) {
let quotes_settings = &settings.flake8_quotes;
let supports_pep701 = settings.target_version.supports_pep701();
let mut fstrings: Vec<FStringContext> = Vec::new();
let mut state_machine = StateMachine::default();
for &(ref tok, tok_range) in lxr.iter().flatten() {
let is_docstring = state_machine.consume(tok);
if is_docstring {
continue;
}
if !supports_pep701 {
// If this is a string or a start of a f-string which is inside another
// f-string, we won't check for escaped quotes for the entire f-string
// if the target version doesn't support PEP 701. For example:
//
// ```python
// f"\"foo\" {'nested'}"
// # ^^^^^^^^
// # We're here
// ```
//
// If we try to fix the above example, the outer and inner quote
// will be the same which is invalid pre 3.12:
//
// ```python
// f'"foo" {'nested'}"
// ```
if matches!(tok, Tok::String { .. } | Tok::FStringStart) {
if let Some(fstring_context) = fstrings.last_mut() {
fstring_context.ignore_escaped_quotes();
continue;
}
}
}
match tok {
Tok::String {
value: string_contents,
kind,
triple_quoted,
} => {
if kind.is_raw() || *triple_quoted {
continue;
}
// Check if we're using the preferred quotation style.
if !leading_quote(locator.slice(tok_range))
.is_some_and(|text| text.contains(quotes_settings.inline_quotes.as_char()))
{
continue;
}
if string_contents.contains(quotes_settings.inline_quotes.as_char())
&& !string_contents.contains(quotes_settings.inline_quotes.opposite().as_char())
{
let mut diagnostic = Diagnostic::new(AvoidableEscapedQuote, tok_range);
if settings.rules.should_fix(diagnostic.kind.rule()) {
let fixed_contents = format!(
"{prefix}{quote}{value}{quote}",
prefix = kind.as_str(),
quote = quotes_settings.inline_quotes.opposite().as_char(),
value = unescape_string(string_contents)
);
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
fixed_contents,
tok_range,
)));
}
diagnostics.push(diagnostic);
}
}
Tok::FStringStart => {
let text = locator.slice(tok_range);
// Check for escaped quote only if we're using the preferred quotation
// style and it isn't a triple-quoted f-string.
let check_for_escaped_quote = text
.contains(quotes_settings.inline_quotes.as_char())
&& !is_triple_quote(text);
fstrings.push(FStringContext::new(check_for_escaped_quote, tok_range));
}
Tok::FStringMiddle {
value: string_contents,
is_raw,
} if !is_raw => {
let Some(context) = fstrings.last_mut() else {
continue;
};
if !context.check_for_escaped_quote {
continue;
}
// If any part of the f-string contains the opposite quote,
// we can't change the quote style in the entire f-string.
if string_contents.contains(quotes_settings.inline_quotes.opposite().as_char()) {
context.ignore_escaped_quotes();
continue;
}
if string_contents.contains(quotes_settings.inline_quotes.as_char()) {
context.push_fstring_middle_range(tok_range);
}
}
Tok::FStringEnd => {
let Some(context) = fstrings.pop() else {
continue;
};
if context.middle_ranges_with_escapes.is_empty() {
// There are no `FStringMiddle` tokens containing any escaped
// quotes.
continue;
}
let mut diagnostic = Diagnostic::new(
AvoidableEscapedQuote,
TextRange::new(context.start_range.start(), tok_range.end()),
);
if settings.rules.should_fix(diagnostic.kind.rule()) {
let fstring_start_edit = Edit::range_replacement(
// No need for `r`/`R` as we don't perform the checks
// for raw strings.
format!("f{}", quotes_settings.inline_quotes.opposite().as_char()),
context.start_range,
);
let fstring_middle_and_end_edits = context
.middle_ranges_with_escapes
.iter()
.map(|&range| {
Edit::range_replacement(unescape_string(locator.slice(range)), range)
})
.chain(std::iter::once(
// `FStringEnd` edit
Edit::range_replacement(
quotes_settings
.inline_quotes
.opposite()
.as_char()
.to_string(),
tok_range,
),
));
diagnostic.set_fix(Fix::automatic_edits(
fstring_start_edit,
fstring_middle_and_end_edits,
));
}
diagnostics.push(diagnostic);
}
_ => {}
}
}
}
fn unescape_string(value: &str) -> String {
let mut fixed_contents = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(char) = chars.next() {
if char != '\\' {
fixed_contents.push(char);
continue;
}
// If we're at the end of the line
let Some(next_char) = chars.peek() else {
fixed_contents.push(char);
continue;
};
// Remove quote escape
if matches!(*next_char, '\'' | '"') {
continue;
}
fixed_contents.push(char);
}
fixed_contents
}

View file

@ -1,6 +1,6 @@
use ruff_python_parser::lexer::LexResult; use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::Tok; use ruff_python_parser::Tok;
use ruff_text_size::TextRange; use ruff_text_size::{TextRange, TextSize};
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
@ -34,22 +34,22 @@ use super::super::settings::Quote;
/// - `flake8-quotes.inline-quotes` /// - `flake8-quotes.inline-quotes`
#[violation] #[violation]
pub struct BadQuotesInlineString { pub struct BadQuotesInlineString {
quote: Quote, preferred_quote: Quote,
} }
impl AlwaysFixableViolation for BadQuotesInlineString { impl AlwaysFixableViolation for BadQuotesInlineString {
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
let BadQuotesInlineString { quote } = self; let BadQuotesInlineString { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => format!("Single quotes found but double quotes preferred"), Quote::Double => format!("Single quotes found but double quotes preferred"),
Quote::Single => format!("Double quotes found but single quotes preferred"), Quote::Single => format!("Double quotes found but single quotes preferred"),
} }
} }
fn fix_title(&self) -> String { fn fix_title(&self) -> String {
let BadQuotesInlineString { quote } = self; let BadQuotesInlineString { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => "Replace single quotes with double quotes".to_string(), Quote::Double => "Replace single quotes with double quotes".to_string(),
Quote::Single => "Replace double quotes with single quotes".to_string(), Quote::Single => "Replace double quotes with single quotes".to_string(),
} }
@ -83,22 +83,22 @@ impl AlwaysFixableViolation for BadQuotesInlineString {
/// - `flake8-quotes.multiline-quotes` /// - `flake8-quotes.multiline-quotes`
#[violation] #[violation]
pub struct BadQuotesMultilineString { pub struct BadQuotesMultilineString {
quote: Quote, preferred_quote: Quote,
} }
impl AlwaysFixableViolation for BadQuotesMultilineString { impl AlwaysFixableViolation for BadQuotesMultilineString {
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
let BadQuotesMultilineString { quote } = self; let BadQuotesMultilineString { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => format!("Single quote multiline found but double quotes preferred"), Quote::Double => format!("Single quote multiline found but double quotes preferred"),
Quote::Single => format!("Double quote multiline found but single quotes preferred"), Quote::Single => format!("Double quote multiline found but single quotes preferred"),
} }
} }
fn fix_title(&self) -> String { fn fix_title(&self) -> String {
let BadQuotesMultilineString { quote } = self; let BadQuotesMultilineString { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => "Replace single multiline quotes with double quotes".to_string(), Quote::Double => "Replace single multiline quotes with double quotes".to_string(),
Quote::Single => "Replace double multiline quotes with single quotes".to_string(), Quote::Single => "Replace double multiline quotes with single quotes".to_string(),
} }
@ -131,73 +131,28 @@ impl AlwaysFixableViolation for BadQuotesMultilineString {
/// - `flake8-quotes.docstring-quotes` /// - `flake8-quotes.docstring-quotes`
#[violation] #[violation]
pub struct BadQuotesDocstring { pub struct BadQuotesDocstring {
quote: Quote, preferred_quote: Quote,
} }
impl AlwaysFixableViolation for BadQuotesDocstring { impl AlwaysFixableViolation for BadQuotesDocstring {
#[derive_message_formats] #[derive_message_formats]
fn message(&self) -> String { fn message(&self) -> String {
let BadQuotesDocstring { quote } = self; let BadQuotesDocstring { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => format!("Single quote docstring found but double quotes preferred"), Quote::Double => format!("Single quote docstring found but double quotes preferred"),
Quote::Single => format!("Double quote docstring found but single quotes preferred"), Quote::Single => format!("Double quote docstring found but single quotes preferred"),
} }
} }
fn fix_title(&self) -> String { fn fix_title(&self) -> String {
let BadQuotesDocstring { quote } = self; let BadQuotesDocstring { preferred_quote } = self;
match quote { match preferred_quote {
Quote::Double => "Replace single quotes docstring with double quotes".to_string(), Quote::Double => "Replace single quotes docstring with double quotes".to_string(),
Quote::Single => "Replace double quotes docstring with single quotes".to_string(), Quote::Single => "Replace double quotes docstring with single quotes".to_string(),
} }
} }
} }
/// ## What it does
/// Checks for strings that include escaped quotes, and suggests changing
/// the quote style to avoid the need to escape them.
///
/// ## Why is this bad?
/// It's preferable to avoid escaped quotes in strings. By changing the
/// outer quote style, you can avoid escaping inner quotes.
///
/// ## Example
/// ```python
/// foo = 'bar\'s'
/// ```
///
/// Use instead:
/// ```python
/// foo = "bar's"
/// ```
#[violation]
pub struct AvoidableEscapedQuote;
impl AlwaysFixableViolation for AvoidableEscapedQuote {
#[derive_message_formats]
fn message(&self) -> String {
format!("Change outer quotes to avoid escaping inner quotes")
}
fn fix_title(&self) -> String {
"Change outer quotes to avoid escaping inner quotes".to_string()
}
}
const fn good_single(quote: Quote) -> char {
match quote {
Quote::Double => '"',
Quote::Single => '\'',
}
}
const fn bad_single(quote: Quote) -> char {
match quote {
Quote::Double => '\'',
Quote::Single => '"',
}
}
const fn good_multiline(quote: Quote) -> &'static str { const fn good_multiline(quote: Quote) -> &'static str {
match quote { match quote {
Quote::Double => "\"\"\"", Quote::Double => "\"\"\"",
@ -219,6 +174,7 @@ const fn good_docstring(quote: Quote) -> &'static str {
} }
} }
#[derive(Debug)]
struct Trivia<'a> { struct Trivia<'a> {
last_quote_char: char, last_quote_char: char,
prefix: &'a str, prefix: &'a str,
@ -254,7 +210,7 @@ impl<'a> From<&'a str> for Trivia<'a> {
} }
} }
/// Q003 /// Q002
fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) -> Option<Diagnostic> { fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) -> Option<Diagnostic> {
let quotes_settings = &settings.flake8_quotes; let quotes_settings = &settings.flake8_quotes;
@ -270,7 +226,7 @@ fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) ->
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
BadQuotesDocstring { BadQuotesDocstring {
quote: quotes_settings.docstring_quotes, preferred_quote: quotes_settings.docstring_quotes,
}, },
range, range,
); );
@ -292,7 +248,7 @@ fn docstring(locator: &Locator, range: TextRange, settings: &LinterSettings) ->
Some(diagnostic) Some(diagnostic)
} }
/// Q001, Q002 /// Q000, Q001
fn strings( fn strings(
locator: &Locator, locator: &Locator,
sequence: &[TextRange], sequence: &[TextRange],
@ -318,12 +274,12 @@ fn strings(
return false; return false;
} }
if trivia.last_quote_char == good_single(quotes_settings.inline_quotes) { if trivia.last_quote_char == quotes_settings.inline_quotes.as_char() {
return false; return false;
} }
let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1]; let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1];
string_contents.contains(good_single(quotes_settings.inline_quotes)) string_contents.contains(quotes_settings.inline_quotes.as_char())
}); });
for (range, trivia) in sequence.iter().zip(trivia) { for (range, trivia) in sequence.iter().zip(trivia) {
@ -346,7 +302,7 @@ fn strings(
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
BadQuotesMultilineString { BadQuotesMultilineString {
quote: quotes_settings.multiline_quotes, preferred_quote: quotes_settings.multiline_quotes,
}, },
*range, *range,
); );
@ -367,107 +323,90 @@ fn strings(
))); )));
} }
diagnostics.push(diagnostic); diagnostics.push(diagnostic);
} else { } else if trivia.last_quote_char != quotes_settings.inline_quotes.as_char()
let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1];
// If we're using the preferred quotation type, check for escapes.
if trivia.last_quote_char == good_single(quotes_settings.inline_quotes) {
if !quotes_settings.avoid_escape
|| trivia.prefix.contains('r')
|| trivia.prefix.contains('R')
{
continue;
}
if string_contents.contains(good_single(quotes_settings.inline_quotes))
&& !string_contents.contains(bad_single(quotes_settings.inline_quotes))
{
let mut diagnostic = Diagnostic::new(AvoidableEscapedQuote, *range);
if settings.rules.should_fix(Rule::AvoidableEscapedQuote) {
let quote = bad_single(quotes_settings.inline_quotes);
let mut fixed_contents =
String::with_capacity(trivia.prefix.len() + string_contents.len() + 2);
fixed_contents.push_str(trivia.prefix);
fixed_contents.push(quote);
let chars: Vec<char> = string_contents.chars().collect();
let mut backslash_count = 0;
for col_offset in 0..chars.len() {
let char = chars[col_offset];
if char != '\\' {
fixed_contents.push(char);
continue;
}
backslash_count += 1;
// If the previous character was also a backslash
if col_offset > 0
&& chars[col_offset - 1] == '\\'
&& backslash_count == 2
{
fixed_contents.push(char);
// reset to 0
backslash_count = 0;
continue;
}
// If we're at the end of the line
if col_offset == chars.len() - 1 {
fixed_contents.push(char);
continue;
}
let next_char = chars[col_offset + 1];
// Remove quote escape
if next_char == '\'' || next_char == '"' {
// reset to 0
backslash_count = 0;
continue;
}
fixed_contents.push(char);
}
fixed_contents.push(quote);
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
fixed_contents,
*range,
)));
}
diagnostics.push(diagnostic);
}
continue;
}
// If we're not using the preferred type, only allow use to avoid escapes. // If we're not using the preferred type, only allow use to avoid escapes.
if !relax_quote { && !relax_quote
let mut diagnostic = Diagnostic::new( {
BadQuotesInlineString { let mut diagnostic = Diagnostic::new(
quote: quotes_settings.inline_quotes, BadQuotesInlineString {
}, preferred_quote: quotes_settings.inline_quotes,
},
*range,
);
if settings.rules.should_fix(Rule::BadQuotesInlineString) {
let quote = quotes_settings.inline_quotes.as_char();
let string_contents = &trivia.raw_text[1..trivia.raw_text.len() - 1];
let mut fixed_contents =
String::with_capacity(trivia.prefix.len() + string_contents.len() + 2);
fixed_contents.push_str(trivia.prefix);
fixed_contents.push(quote);
fixed_contents.push_str(string_contents);
fixed_contents.push(quote);
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
fixed_contents,
*range, *range,
); )));
if settings.rules.should_fix(Rule::BadQuotesInlineString) {
let quote = good_single(quotes_settings.inline_quotes);
let mut fixed_contents =
String::with_capacity(trivia.prefix.len() + string_contents.len() + 2);
fixed_contents.push_str(trivia.prefix);
fixed_contents.push(quote);
fixed_contents.push_str(string_contents);
fixed_contents.push(quote);
diagnostic.set_fix(Fix::automatic(Edit::range_replacement(
fixed_contents,
*range,
)));
}
diagnostics.push(diagnostic);
} }
diagnostics.push(diagnostic);
} }
} }
diagnostics diagnostics
} }
/// A builder for the f-string range.
///
/// For now, this is limited to the outermost f-string and doesn't support
/// nested f-strings.
#[derive(Debug, Default)]
struct FStringRangeBuilder {
start_location: TextSize,
end_location: TextSize,
nesting: u32,
}
impl FStringRangeBuilder {
fn visit_token(&mut self, token: &Tok, range: TextRange) {
match token {
Tok::FStringStart => {
if self.nesting == 0 {
self.start_location = range.start();
}
self.nesting += 1;
}
Tok::FStringEnd => {
self.nesting = self.nesting.saturating_sub(1);
if self.nesting == 0 {
self.end_location = range.end();
}
}
_ => {}
}
}
/// Returns `true` if the lexer is currently inside of a f-string.
///
/// It'll return `false` once the `FStringEnd` token for the outermost
/// f-string is visited.
const fn in_fstring(&self) -> bool {
self.nesting > 0
}
/// Returns the complete range of the previously visited f-string.
///
/// This method should only be called once the lexer is outside of any
/// f-string otherwise it might return an invalid range.
///
/// It doesn't consume the builder because there can be multiple f-strings
/// throughout the source code.
fn finish(&self) -> TextRange {
debug_assert!(!self.in_fstring());
TextRange::new(self.start_location, self.end_location)
}
}
/// Generate `flake8-quote` diagnostics from a token stream. /// Generate `flake8-quote` diagnostics from a token stream.
pub(crate) fn from_tokens( pub(crate) fn check_string_quotes(
diagnostics: &mut Vec<Diagnostic>, diagnostics: &mut Vec<Diagnostic>,
lxr: &[LexResult], lxr: &[LexResult],
locator: &Locator, locator: &Locator,
@ -477,7 +416,13 @@ pub(crate) fn from_tokens(
// concatenation, and should thus be handled as a single unit. // concatenation, and should thus be handled as a single unit.
let mut sequence = vec![]; let mut sequence = vec![];
let mut state_machine = StateMachine::default(); let mut state_machine = StateMachine::default();
let mut fstring_range_builder = FStringRangeBuilder::default();
for &(ref tok, range) in lxr.iter().flatten() { for &(ref tok, range) in lxr.iter().flatten() {
fstring_range_builder.visit_token(tok, range);
if fstring_range_builder.in_fstring() {
continue;
}
let is_docstring = state_machine.consume(tok); let is_docstring = state_machine.consume(tok);
// If this is a docstring, consume the existing sequence, then consume the // If this is a docstring, consume the existing sequence, then consume the
@ -491,14 +436,23 @@ pub(crate) fn from_tokens(
diagnostics.push(diagnostic); diagnostics.push(diagnostic);
} }
} else { } else {
if tok.is_string() { match tok {
// If this is a string, add it to the sequence. Tok::String { .. } => {
sequence.push(range); // If this is a string, add it to the sequence.
} else if !matches!(tok, Tok::Comment(..) | Tok::NonLogicalNewline) { sequence.push(range);
// Otherwise, consume the sequence. }
if !sequence.is_empty() { Tok::FStringEnd => {
diagnostics.extend(strings(locator, &sequence, settings)); // If this is the end of an f-string, add the entire f-string
sequence.clear(); // range to the sequence.
sequence.push(fstring_range_builder.finish());
}
Tok::Comment(..) | Tok::NonLogicalNewline => continue,
_ => {
// Otherwise, consume the sequence.
if !sequence.is_empty() {
diagnostics.extend(strings(locator, &sequence, settings));
sequence.clear();
}
} }
} }
} }

View file

@ -1,3 +1,5 @@
pub(crate) use from_tokens::*; pub(crate) use avoidable_escaped_quote::*;
pub(crate) use check_string_quotes::*;
mod from_tokens; mod avoidable_escaped_quote;
mod check_string_quotes;

View file

@ -38,3 +38,21 @@ impl Default for Settings {
} }
} }
} }
impl Quote {
#[must_use]
pub const fn opposite(self) -> Self {
match self {
Self::Double => Self::Single,
Self::Single => Self::Double,
}
}
/// Get the character used to represent this quote.
pub const fn as_char(self) -> char {
match self {
Self::Double => '"',
Self::Single => '\'',
}
}
}

View file

@ -34,5 +34,184 @@ singles_escaped.py:9:5: Q003 [*] Change outer quotes to avoid escaping inner quo
9 |- "\"string\"" 9 |- "\"string\""
9 |+ '"string"' 9 |+ '"string"'
10 10 | ) 10 10 | )
11 11 |
12 12 | # Same as above, but with f-strings
singles_escaped.py:13:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
12 | # Same as above, but with f-strings
13 | f"This is a \"string\""
| ^^^^^^^^^^^^^^^^^^^^^^^ Q003
14 | f"'This' is a \"string\""
15 | f'This is a "string"'
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
10 10 | )
11 11 |
12 12 | # Same as above, but with f-strings
13 |-f"This is a \"string\""
13 |+f'This is a "string"'
14 14 | f"'This' is a \"string\""
15 15 | f'This is a "string"'
16 16 | f'\'This\' is a "string"'
singles_escaped.py:21:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
19 | foo = (
20 | f"This is a"
21 | f"\"string\""
| ^^^^^^^^^^^^^ Q003
22 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
18 18 | fR"This is a \"string\""
19 19 | foo = (
20 20 | f"This is a"
21 |- f"\"string\""
21 |+ f'"string"'
22 22 | )
23 23 |
24 24 | # Nested f-strings (Python 3.12+)
singles_escaped.py:31:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
29 | #
30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 | f"\"foo\" {"foo"}" # Q003
| ^^^^^^^^^^^^^^^^^^ Q003
32 | f"\"foo\" {f"foo"}" # Q003
33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
28 28 | # f'"foo" {"nested"}'
29 29 | #
30 30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 |-f"\"foo\" {"foo"}" # Q003
31 |+f'"foo" {"foo"}' # Q003
32 32 | f"\"foo\" {f"foo"}" # Q003
33 33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
34 34 |
singles_escaped.py:32:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 | f"\"foo\" {"foo"}" # Q003
32 | f"\"foo\" {f"foo"}" # Q003
| ^^^^^^^^^^^^^^^^^^^ Q003
33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
29 29 | #
30 30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 31 | f"\"foo\" {"foo"}" # Q003
32 |-f"\"foo\" {f"foo"}" # Q003
32 |+f'"foo" {f"foo"}' # Q003
33 33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
34 34 |
35 35 | f"normal {f"nested"} normal"
singles_escaped.py:33:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
31 | f"\"foo\" {"foo"}" # Q003
32 | f"\"foo\" {f"foo"}" # Q003
33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
34 |
35 | f"normal {f"nested"} normal"
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
30 30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 31 | f"\"foo\" {"foo"}" # Q003
32 32 | f"\"foo\" {f"foo"}" # Q003
33 |-f"\"foo\" {f"\"foo\""} \"\"" # Q003
33 |+f'"foo" {f"\"foo\""} ""' # Q003
34 34 |
35 35 | f"normal {f"nested"} normal"
36 36 | f"\"normal\" {f"nested"} normal" # Q003
singles_escaped.py:33:12: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
31 | f"\"foo\" {"foo"}" # Q003
32 | f"\"foo\" {f"foo"}" # Q003
33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
| ^^^^^^^^^^ Q003
34 |
35 | f"normal {f"nested"} normal"
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
30 30 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
31 31 | f"\"foo\" {"foo"}" # Q003
32 32 | f"\"foo\" {f"foo"}" # Q003
33 |-f"\"foo\" {f"\"foo\""} \"\"" # Q003
33 |+f"\"foo\" {f'"foo"'} \"\"" # Q003
34 34 |
35 35 | f"normal {f"nested"} normal"
36 36 | f"\"normal\" {f"nested"} normal" # Q003
singles_escaped.py:36:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
35 | f"normal {f"nested"} normal"
36 | f"\"normal\" {f"nested"} normal" # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
37 | f"\"normal\" {f"nested"} 'single quotes'"
38 | f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
33 33 | f"\"foo\" {f"\"foo\""} \"\"" # Q003
34 34 |
35 35 | f"normal {f"nested"} normal"
36 |-f"\"normal\" {f"nested"} normal" # Q003
36 |+f'"normal" {f"nested"} normal' # Q003
37 37 | f"\"normal\" {f"nested"} 'single quotes'"
38 38 | f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
39 39 | f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003
singles_escaped.py:38:15: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
36 | f"\"normal\" {f"nested"} normal" # Q003
37 | f"\"normal\" {f"nested"} 'single quotes'"
38 | f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
39 | f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
35 35 | f"normal {f"nested"} normal"
36 36 | f"\"normal\" {f"nested"} normal" # Q003
37 37 | f"\"normal\" {f"nested"} 'single quotes'"
38 |-f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
38 |+f"\"normal\" {f'"nested" {"other"} normal'} 'single quotes'" # Q003
39 39 | f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003
singles_escaped.py:39:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
37 | f"\"normal\" {f"nested"} 'single quotes'"
38 | f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
39 | f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
36 36 | f"\"normal\" {f"nested"} normal" # Q003
37 37 | f"\"normal\" {f"nested"} 'single quotes'"
38 38 | f"\"normal\" {f"\"nested\" {"other"} normal"} 'single quotes'" # Q003
39 |-f"\"normal\" {f"\"nested\" {"other"} 'single quotes'"} normal" # Q003
39 |+f'"normal" {f"\"nested\" {"other"} 'single quotes'"} normal' # Q003

View file

@ -0,0 +1,80 @@
---
source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs
---
singles_escaped.py:1:26: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
1 | this_should_raise_Q003 = "This is a \"string\""
| ^^^^^^^^^^^^^^^^^^^^^^ Q003
2 | this_is_fine = "'This' is a \"string\""
3 | this_is_fine = 'This is a "string"'
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
1 |-this_should_raise_Q003 = "This is a \"string\""
1 |+this_should_raise_Q003 = 'This is a "string"'
2 2 | this_is_fine = "'This' is a \"string\""
3 3 | this_is_fine = 'This is a "string"'
4 4 | this_is_fine = '\'This\' is a "string"'
singles_escaped.py:9:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
7 | this_should_raise = (
8 | "This is a"
9 | "\"string\""
| ^^^^^^^^^^^^ Q003
10 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
6 6 | this_is_fine = R"This is a \"string\""
7 7 | this_should_raise = (
8 8 | "This is a"
9 |- "\"string\""
9 |+ '"string"'
10 10 | )
11 11 |
12 12 | # Same as above, but with f-strings
singles_escaped.py:13:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
12 | # Same as above, but with f-strings
13 | f"This is a \"string\""
| ^^^^^^^^^^^^^^^^^^^^^^^ Q003
14 | f"'This' is a \"string\""
15 | f'This is a "string"'
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
10 10 | )
11 11 |
12 12 | # Same as above, but with f-strings
13 |-f"This is a \"string\""
13 |+f'This is a "string"'
14 14 | f"'This' is a \"string\""
15 15 | f'This is a "string"'
16 16 | f'\'This\' is a "string"'
singles_escaped.py:21:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
19 | foo = (
20 | f"This is a"
21 | f"\"string\""
| ^^^^^^^^^^^^^ Q003
22 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
18 18 | fR"This is a \"string\""
19 19 | foo = (
20 20 | f"This is a"
21 |- f"\"string\""
21 |+ f'"string"'
22 22 | )
23 23 |
24 24 | # Nested f-strings (Python 3.12+)

View file

@ -52,5 +52,205 @@ doubles_escaped.py:10:5: Q003 [*] Change outer quotes to avoid escaping inner qu
10 |- '\'string\'' 10 |- '\'string\''
10 |+ "'string'" 10 |+ "'string'"
11 11 | ) 11 11 | )
12 12 |
13 13 | # Same as above, but with f-strings
doubles_escaped.py:14:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
13 | # Same as above, but with f-strings
14 | f'This is a \'string\'' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^ Q003
15 | f'This is \\ a \\\'string\'' # Q003
16 | f'"This" is a \'string\''
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
11 11 | )
12 12 |
13 13 | # Same as above, but with f-strings
14 |-f'This is a \'string\'' # Q003
14 |+f"This is a 'string'" # Q003
15 15 | f'This is \\ a \\\'string\'' # Q003
16 16 | f'"This" is a \'string\''
17 17 | f"This is a 'string'"
doubles_escaped.py:15:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
13 | # Same as above, but with f-strings
14 | f'This is a \'string\'' # Q003
15 | f'This is \\ a \\\'string\'' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
16 | f'"This" is a \'string\''
17 | f"This is a 'string'"
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
12 12 |
13 13 | # Same as above, but with f-strings
14 14 | f'This is a \'string\'' # Q003
15 |-f'This is \\ a \\\'string\'' # Q003
15 |+f"This is \\ a \\'string'" # Q003
16 16 | f'"This" is a \'string\''
17 17 | f"This is a 'string'"
18 18 | f"\"This\" is a 'string'"
doubles_escaped.py:23:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
21 | foo = (
22 | f'This is a'
23 | f'\'string\'' # Q003
| ^^^^^^^^^^^^^ Q003
24 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
20 20 | fR'This is a \'string\''
21 21 | foo = (
22 22 | f'This is a'
23 |- f'\'string\'' # Q003
23 |+ f"'string'" # Q003
24 24 | )
25 25 |
26 26 | # Nested f-strings (Python 3.12+)
doubles_escaped.py:33:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
31 | #
32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 | f'\'foo\' {'nested'}' # Q003
| ^^^^^^^^^^^^^^^^^^^^^ Q003
34 | f'\'foo\' {f'nested'}' # Q003
35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
30 30 | # f"'foo' {'nested'}"
31 31 | #
32 32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 |-f'\'foo\' {'nested'}' # Q003
33 |+f"'foo' {'nested'}" # Q003
34 34 | f'\'foo\' {f'nested'}' # Q003
35 35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
36 36 |
doubles_escaped.py:34:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 | f'\'foo\' {'nested'}' # Q003
34 | f'\'foo\' {f'nested'}' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^ Q003
35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
31 31 | #
32 32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 33 | f'\'foo\' {'nested'}' # Q003
34 |-f'\'foo\' {f'nested'}' # Q003
34 |+f"'foo' {f'nested'}" # Q003
35 35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
36 36 |
37 37 | f'normal {f'nested'} normal'
doubles_escaped.py:35:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
33 | f'\'foo\' {'nested'}' # Q003
34 | f'\'foo\' {f'nested'}' # Q003
35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
36 |
37 | f'normal {f'nested'} normal'
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
32 32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 33 | f'\'foo\' {'nested'}' # Q003
34 34 | f'\'foo\' {f'nested'}' # Q003
35 |-f'\'foo\' {f'\'nested\''} \'\'' # Q003
35 |+f"'foo' {f'\'nested\''} ''" # Q003
36 36 |
37 37 | f'normal {f'nested'} normal'
38 38 | f'\'normal\' {f'nested'} normal' # Q003
doubles_escaped.py:35:12: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
33 | f'\'foo\' {'nested'}' # Q003
34 | f'\'foo\' {f'nested'}' # Q003
35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
| ^^^^^^^^^^^^^ Q003
36 |
37 | f'normal {f'nested'} normal'
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
32 32 | # but as the actual string itself is invalid pre 3.12, we don't catch it.
33 33 | f'\'foo\' {'nested'}' # Q003
34 34 | f'\'foo\' {f'nested'}' # Q003
35 |-f'\'foo\' {f'\'nested\''} \'\'' # Q003
35 |+f'\'foo\' {f"'nested'"} \'\'' # Q003
36 36 |
37 37 | f'normal {f'nested'} normal'
38 38 | f'\'normal\' {f'nested'} normal' # Q003
doubles_escaped.py:38:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
37 | f'normal {f'nested'} normal'
38 | f'\'normal\' {f'nested'} normal' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
39 | f'\'normal\' {f'nested'} "double quotes"'
40 | f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
35 35 | f'\'foo\' {f'\'nested\''} \'\'' # Q003
36 36 |
37 37 | f'normal {f'nested'} normal'
38 |-f'\'normal\' {f'nested'} normal' # Q003
38 |+f"'normal' {f'nested'} normal" # Q003
39 39 | f'\'normal\' {f'nested'} "double quotes"'
40 40 | f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
41 41 | f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l
doubles_escaped.py:40:15: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
38 | f'\'normal\' {f'nested'} normal' # Q003
39 | f'\'normal\' {f'nested'} "double quotes"'
40 | f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
41 | f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
37 37 | f'normal {f'nested'} normal'
38 38 | f'\'normal\' {f'nested'} normal' # Q003
39 39 | f'\'normal\' {f'nested'} "double quotes"'
40 |-f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
40 |+f'\'normal\' {f"'nested' {'other'} normal"} "double quotes"' # Q003
41 41 | f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l
doubles_escaped.py:41:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
39 | f'\'normal\' {f'nested'} "double quotes"'
40 | f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
41 | f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
38 38 | f'\'normal\' {f'nested'} normal' # Q003
39 39 | f'\'normal\' {f'nested'} "double quotes"'
40 40 | f'\'normal\' {f'\'nested\' {'other'} normal'} "double quotes"' # Q003
41 |-f'\'normal\' {f'\'nested\' {'other'} "double quotes"'} normal' # Q00l
41 |+f"'normal' {f'\'nested\' {'other'} "double quotes"'} normal" # Q00l

View file

@ -0,0 +1,119 @@
---
source: crates/ruff_linter/src/rules/flake8_quotes/mod.rs
---
doubles_escaped.py:1:26: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
1 | this_should_raise_Q003 = 'This is a \'string\''
| ^^^^^^^^^^^^^^^^^^^^^^ Q003
2 | this_should_raise_Q003 = 'This is \\ a \\\'string\''
3 | this_is_fine = '"This" is a \'string\''
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
1 |-this_should_raise_Q003 = 'This is a \'string\''
1 |+this_should_raise_Q003 = "This is a 'string'"
2 2 | this_should_raise_Q003 = 'This is \\ a \\\'string\''
3 3 | this_is_fine = '"This" is a \'string\''
4 4 | this_is_fine = "This is a 'string'"
doubles_escaped.py:2:26: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
1 | this_should_raise_Q003 = 'This is a \'string\''
2 | this_should_raise_Q003 = 'This is \\ a \\\'string\''
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
3 | this_is_fine = '"This" is a \'string\''
4 | this_is_fine = "This is a 'string'"
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
1 1 | this_should_raise_Q003 = 'This is a \'string\''
2 |-this_should_raise_Q003 = 'This is \\ a \\\'string\''
2 |+this_should_raise_Q003 = "This is \\ a \\'string'"
3 3 | this_is_fine = '"This" is a \'string\''
4 4 | this_is_fine = "This is a 'string'"
5 5 | this_is_fine = "\"This\" is a 'string'"
doubles_escaped.py:10:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
8 | this_should_raise = (
9 | 'This is a'
10 | '\'string\''
| ^^^^^^^^^^^^ Q003
11 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
7 7 | this_is_fine = R'This is a \'string\''
8 8 | this_should_raise = (
9 9 | 'This is a'
10 |- '\'string\''
10 |+ "'string'"
11 11 | )
12 12 |
13 13 | # Same as above, but with f-strings
doubles_escaped.py:14:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
13 | # Same as above, but with f-strings
14 | f'This is a \'string\'' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^ Q003
15 | f'This is \\ a \\\'string\'' # Q003
16 | f'"This" is a \'string\''
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
11 11 | )
12 12 |
13 13 | # Same as above, but with f-strings
14 |-f'This is a \'string\'' # Q003
14 |+f"This is a 'string'" # Q003
15 15 | f'This is \\ a \\\'string\'' # Q003
16 16 | f'"This" is a \'string\''
17 17 | f"This is a 'string'"
doubles_escaped.py:15:1: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
13 | # Same as above, but with f-strings
14 | f'This is a \'string\'' # Q003
15 | f'This is \\ a \\\'string\'' # Q003
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Q003
16 | f'"This" is a \'string\''
17 | f"This is a 'string'"
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
12 12 |
13 13 | # Same as above, but with f-strings
14 14 | f'This is a \'string\'' # Q003
15 |-f'This is \\ a \\\'string\'' # Q003
15 |+f"This is \\ a \\'string'" # Q003
16 16 | f'"This" is a \'string\''
17 17 | f"This is a 'string'"
18 18 | f"\"This\" is a 'string'"
doubles_escaped.py:23:5: Q003 [*] Change outer quotes to avoid escaping inner quotes
|
21 | foo = (
22 | f'This is a'
23 | f'\'string\'' # Q003
| ^^^^^^^^^^^^^ Q003
24 | )
|
= help: Change outer quotes to avoid escaping inner quotes
Fix
20 20 | fR'This is a \'string\''
21 21 | foo = (
22 22 | f'This is a'
23 |- f'\'string\'' # Q003
23 |+ f"'string'" # Q003
24 24 | )
25 25 |
26 26 | # Nested f-strings (Python 3.12+)

View file

@ -28,6 +28,7 @@ mod tests {
#[test_case(Rule::BlankLineWithWhitespace, Path::new("W29.py"))] #[test_case(Rule::BlankLineWithWhitespace, Path::new("W29.py"))]
#[test_case(Rule::InvalidEscapeSequence, Path::new("W605_0.py"))] #[test_case(Rule::InvalidEscapeSequence, Path::new("W605_0.py"))]
#[test_case(Rule::InvalidEscapeSequence, Path::new("W605_1.py"))] #[test_case(Rule::InvalidEscapeSequence, Path::new("W605_1.py"))]
#[test_case(Rule::InvalidEscapeSequence, Path::new("W605_2.py"))]
#[test_case(Rule::LineTooLong, Path::new("E501.py"))] #[test_case(Rule::LineTooLong, Path::new("E501.py"))]
#[test_case(Rule::MixedSpacesAndTabs, Path::new("E101.py"))] #[test_case(Rule::MixedSpacesAndTabs, Path::new("E101.py"))]
#[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E40.py"))] #[test_case(Rule::ModuleImportNotAtTopOfFile, Path::new("E40.py"))]

View file

@ -2,7 +2,8 @@ use memchr::memchr_iter;
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::str::{leading_quote, trailing_quote}; use ruff_python_index::Indexer;
use ruff_python_parser::Tok;
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
@ -45,29 +46,32 @@ impl AlwaysFixableViolation for InvalidEscapeSequence {
pub(crate) fn invalid_escape_sequence( pub(crate) fn invalid_escape_sequence(
diagnostics: &mut Vec<Diagnostic>, diagnostics: &mut Vec<Diagnostic>,
locator: &Locator, locator: &Locator,
range: TextRange, indexer: &Indexer,
token: &Tok,
token_range: TextRange,
fix: bool, fix: bool,
) { ) {
let text = locator.slice(range); let token_source_code = match token {
Tok::FStringMiddle { value, is_raw } => {
// Determine whether the string is single- or triple-quoted. if *is_raw {
let Some(leading_quote) = leading_quote(text) else { return;
return; }
value.as_str()
}
Tok::String { kind, .. } => {
if kind.is_raw() {
return;
}
locator.slice(token_range)
}
_ => return,
}; };
let Some(trailing_quote) = trailing_quote(text) else {
return;
};
let body = &text[leading_quote.len()..text.len() - trailing_quote.len()];
if leading_quote.contains(['r', 'R']) {
return;
}
let mut contains_valid_escape_sequence = false; let mut contains_valid_escape_sequence = false;
let mut invalid_escape_sequence = Vec::new(); let mut invalid_escape_sequence = Vec::new();
let mut prev = None; let mut prev = None;
let bytes = body.as_bytes(); let bytes = token_source_code.as_bytes();
for i in memchr_iter(b'\\', bytes) { for i in memchr_iter(b'\\', bytes) {
// If the previous character was also a backslash, skip. // If the previous character was also a backslash, skip.
if prev.is_some_and(|prev| prev == i - 1) { if prev.is_some_and(|prev| prev == i - 1) {
@ -77,9 +81,38 @@ pub(crate) fn invalid_escape_sequence(
prev = Some(i); prev = Some(i);
let Some(next_char) = body[i + 1..].chars().next() else { let next_char = match token_source_code[i + 1..].chars().next() {
Some(next_char) => next_char,
None if token.is_f_string_middle() => {
// If we're at the end of a f-string middle token, the next character
// is actually emitted as a different token. For example,
//
// ```python
// f"\{1}"
// ```
//
// is lexed as `FStringMiddle('\\')` and `LBrace` (ignoring irrelevant
// tokens), so we need to check the next character in the source code.
//
// Now, if we're at the end of the f-string itself, the lexer wouldn't
// have emitted the `FStringMiddle` token in the first place. For example,
//
// ```python
// f"foo\"
// ```
//
// Here, there won't be any `FStringMiddle` because it's an unterminated
// f-string. This means that if there's a `FStringMiddle` token and we
// encounter a `\` character, then the next character is always going to
// be part of the f-string.
if let Some(next_char) = locator.after(token_range.end()).chars().next() {
next_char
} else {
continue;
}
}
// If we're at the end of the file, skip. // If we're at the end of the file, skip.
continue; None => continue,
}; };
// If we're at the end of line, skip. // If we're at the end of line, skip.
@ -120,7 +153,7 @@ pub(crate) fn invalid_escape_sequence(
continue; continue;
} }
let location = range.start() + leading_quote.text_len() + TextSize::try_from(i).unwrap(); let location = token_range.start() + TextSize::try_from(i).unwrap();
let range = TextRange::at(location, next_char.text_len() + TextSize::from(1)); let range = TextRange::at(location, next_char.text_len() + TextSize::from(1));
invalid_escape_sequence.push(Diagnostic::new(InvalidEscapeSequence(next_char), range)); invalid_escape_sequence.push(Diagnostic::new(InvalidEscapeSequence(next_char), range));
} }
@ -135,14 +168,25 @@ pub(crate) fn invalid_escape_sequence(
))); )));
} }
} else { } else {
let tok_start = if token.is_f_string_middle() {
// SAFETY: If this is a `FStringMiddle` token, then the indexer
// must have the f-string range.
indexer
.fstring_ranges()
.innermost(token_range.start())
.unwrap()
.start()
} else {
token_range.start()
};
// Turn into raw string. // Turn into raw string.
for diagnostic in &mut invalid_escape_sequence { for diagnostic in &mut invalid_escape_sequence {
// If necessary, add a space between any leading keyword (`return`, `yield`, // If necessary, add a space between any leading keyword (`return`, `yield`,
// `assert`, etc.) and the string. For example, `return"foo"` is valid, but // `assert`, etc.) and the string. For example, `return"foo"` is valid, but
// `returnr"foo"` is not. // `returnr"foo"` is not.
diagnostic.set_fix(Fix::automatic(Edit::insertion( diagnostic.set_fix(Fix::automatic(Edit::insertion(
pad_start("r".to_string(), range.start(), locator), pad_start("r".to_string(), tok_start, locator),
range.start(), tok_start,
))); )));
} }
} }

View file

@ -134,12 +134,27 @@ pub(crate) fn extraneous_whitespace(
fix_before_punctuation: bool, fix_before_punctuation: bool,
) { ) {
let mut prev_token = None; let mut prev_token = None;
let mut fstrings = 0u32;
for token in line.tokens() { for token in line.tokens() {
let kind = token.kind(); let kind = token.kind();
match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
_ => {}
}
if let Some(symbol) = BracketOrPunctuation::from_kind(kind) { if let Some(symbol) = BracketOrPunctuation::from_kind(kind) {
// Whitespace before "{" or after "}" might be required in f-strings.
// For example,
//
// ```python
// f"{ {'a': 1} }"
// ```
//
// Here, `{{` / `}} would be interpreted as a single raw `{` / `}`
// character.
match symbol { match symbol {
BracketOrPunctuation::OpenBracket(symbol) => { BracketOrPunctuation::OpenBracket(symbol) if symbol != '{' || fstrings == 0 => {
let (trailing, trailing_len) = line.trailing_whitespace(token); let (trailing, trailing_len) = line.trailing_whitespace(token);
if !matches!(trailing, Whitespace::None) { if !matches!(trailing, Whitespace::None) {
let mut diagnostic = Diagnostic::new( let mut diagnostic = Diagnostic::new(
@ -153,7 +168,7 @@ pub(crate) fn extraneous_whitespace(
context.push_diagnostic(diagnostic); context.push_diagnostic(diagnostic);
} }
} }
BracketOrPunctuation::CloseBracket(symbol) => { BracketOrPunctuation::CloseBracket(symbol) if symbol != '}' || fstrings == 0 => {
if !matches!(prev_token, Some(TokenKind::Comma)) { if !matches!(prev_token, Some(TokenKind::Comma)) {
if let (Whitespace::Single | Whitespace::Many | Whitespace::Tab, offset) = if let (Whitespace::Single | Whitespace::Many | Whitespace::Tab, offset) =
line.leading_whitespace(token) line.leading_whitespace(token)
@ -189,6 +204,7 @@ pub(crate) fn extraneous_whitespace(
} }
} }
} }
_ => {}
} }
} }

View file

@ -55,6 +55,7 @@ impl AlwaysFixableViolation for MissingWhitespace {
/// E231 /// E231
pub(crate) fn missing_whitespace(line: &LogicalLine, fix: bool, context: &mut LogicalLinesContext) { pub(crate) fn missing_whitespace(line: &LogicalLine, fix: bool, context: &mut LogicalLinesContext) {
let mut open_parentheses = 0u32; let mut open_parentheses = 0u32;
let mut fstrings = 0u32;
let mut prev_lsqb = TextSize::default(); let mut prev_lsqb = TextSize::default();
let mut prev_lbrace = TextSize::default(); let mut prev_lbrace = TextSize::default();
let mut iter = line.tokens().iter().peekable(); let mut iter = line.tokens().iter().peekable();
@ -62,6 +63,8 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, fix: bool, context: &mut Lo
while let Some(token) = iter.next() { while let Some(token) = iter.next() {
let kind = token.kind(); let kind = token.kind();
match kind { match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
TokenKind::Lsqb => { TokenKind::Lsqb => {
open_parentheses = open_parentheses.saturating_add(1); open_parentheses = open_parentheses.saturating_add(1);
prev_lsqb = token.start(); prev_lsqb = token.start();
@ -72,6 +75,17 @@ pub(crate) fn missing_whitespace(line: &LogicalLine, fix: bool, context: &mut Lo
TokenKind::Lbrace => { TokenKind::Lbrace => {
prev_lbrace = token.start(); prev_lbrace = token.start();
} }
TokenKind::Colon if fstrings > 0 => {
// Colon in f-string, no space required. This will yield false
// negatives for cases like the following as it's hard to
// differentiate between the usage of a colon in a f-string.
//
// ```python
// f'{ {'x':1} }'
// f'{(lambda x:x)}'
// ```
continue;
}
TokenKind::Comma | TokenKind::Semi | TokenKind::Colon => { TokenKind::Comma | TokenKind::Semi | TokenKind::Colon => {
let after = line.text_after(token); let after = line.text_after(token);

View file

@ -141,6 +141,7 @@ pub(crate) fn missing_whitespace_around_operator(
prev_token.kind(), prev_token.kind(),
TokenKind::Lpar | TokenKind::Lambda TokenKind::Lpar | TokenKind::Lambda
)); ));
let mut fstrings = u32::from(matches!(prev_token.kind(), TokenKind::FStringStart));
while let Some(token) = tokens.next() { while let Some(token) = tokens.next() {
let kind = token.kind(); let kind = token.kind();
@ -150,13 +151,15 @@ pub(crate) fn missing_whitespace_around_operator(
} }
match kind { match kind {
TokenKind::FStringStart => fstrings += 1,
TokenKind::FStringEnd => fstrings = fstrings.saturating_sub(1),
TokenKind::Lpar | TokenKind::Lambda => parens += 1, TokenKind::Lpar | TokenKind::Lambda => parens += 1,
TokenKind::Rpar => parens = parens.saturating_sub(1), TokenKind::Rpar => parens = parens.saturating_sub(1),
_ => {} _ => {}
}; };
let needs_space = if kind == TokenKind::Equal && parens > 0 { let needs_space = if kind == TokenKind::Equal && (parens > 0 || fstrings > 0) {
// Allow keyword args or defaults: foo(bar=None). // Allow keyword args, defaults: foo(bar=None) and f-strings: f'{foo=}'
NeedsSpace::No NeedsSpace::No
} else if kind == TokenKind::Slash { } else if kind == TokenKind::Slash {
// Tolerate the "/" operator in function definition // Tolerate the "/" operator in function definition

View file

@ -26,7 +26,7 @@ use crate::rules::pycodestyle::rules::logical_lines::{LogicalLine, LogicalLineTo
/// ///
/// Use instead: /// Use instead:
/// ```python /// ```python
/// def add(a = 0) -> int: /// def add(a=0) -> int:
/// return a + 1 /// return a + 1
/// ``` /// ```
/// ///

View file

@ -144,4 +144,22 @@ E20.py:81:6: E201 [*] Whitespace after '['
83 83 | #: Okay 83 83 | #: Okay
84 84 | x = [ # 84 84 | x = [ #
E20.py:90:5: E201 [*] Whitespace after '['
|
88 | # F-strings
89 | f"{ {'a': 1} }"
90 | f"{[ { {'a': 1} } ]}"
| ^ E201
91 | f"normal { {f"{ { [1, 2] } }" } } normal"
|
= help: Remove whitespace before '['
Fix
87 87 |
88 88 | # F-strings
89 89 | f"{ {'a': 1} }"
90 |-f"{[ { {'a': 1} } ]}"
90 |+f"{[{ {'a': 1} } ]}"
91 91 | f"normal { {f"{ { [1, 2] } }" } } normal"

View file

@ -126,4 +126,22 @@ E20.py:29:11: E202 [*] Whitespace before ']'
31 31 | spam(ham[1], {eggs: 2}) 31 31 | spam(ham[1], {eggs: 2})
32 32 | 32 32 |
E20.py:90:18: E202 [*] Whitespace before ']'
|
88 | # F-strings
89 | f"{ {'a': 1} }"
90 | f"{[ { {'a': 1} } ]}"
| ^ E202
91 | f"normal { {f"{ { [1, 2] } }" } } normal"
|
= help: Remove whitespace before ']'
Fix
87 87 |
88 88 | # F-strings
89 89 | f"{ {'a': 1} }"
90 |-f"{[ { {'a': 1} } ]}"
90 |+f"{[ { {'a': 1} }]}"
91 91 | f"normal { {f"{ { [1, 2] } }" } } normal"

View file

@ -99,6 +99,26 @@ E23.py:29:20: E231 [*] Missing whitespace after ':'
29 |+ 'tag_smalldata': [('byte_count_mdtype', 'u4'), ('data', 'S4')], 29 |+ 'tag_smalldata': [('byte_count_mdtype', 'u4'), ('data', 'S4')],
30 30 | } 30 30 | }
31 31 | 31 31 |
32 32 | #: Okay 32 32 | # E231
E23.py:33:6: E231 [*] Missing whitespace after ','
|
32 | # E231
33 | f"{(a,b)}"
| ^ E231
34 |
35 | # Okay because it's hard to differentiate between the usages of a colon in a f-string
|
= help: Added missing whitespace after ','
Fix
30 30 | }
31 31 |
32 32 | # E231
33 |-f"{(a,b)}"
33 |+f"{(a, b)}"
34 34 |
35 35 | # Okay because it's hard to differentiate between the usages of a colon in a f-string
36 36 | f"{a:=1}"

View file

@ -349,4 +349,20 @@ W19.py:146:1: W191 Indentation contains tabs
148 | #: W191 - okay 148 | #: W191 - okay
| |
W19.py:157:1: W191 Indentation contains tabs
|
156 | f"test{
157 | tab_indented_should_be_flagged
| ^^^^ W191
158 | } <- this tab is fine"
|
W19.py:161:1: W191 Indentation contains tabs
|
160 | f"""test{
161 | tab_indented_should_be_flagged
| ^^^^ W191
162 | } <- this tab is fine"""
|

View file

@ -0,0 +1,227 @@
---
source: crates/ruff/src/rules/pycodestyle/mod.rs
---
W605_2.py:4:11: W605 [*] Invalid escape sequence: `\.`
|
3 | #: W605:1:10
4 | regex = f'\.png$'
| ^^ W605
5 |
6 | #: W605:2:1
|
= help: Add backslash to escape sequence
Fix
1 1 | # Same as `W605_0.py` but using f-strings instead.
2 2 |
3 3 | #: W605:1:10
4 |-regex = f'\.png$'
4 |+regex = rf'\.png$'
5 5 |
6 6 | #: W605:2:1
7 7 | regex = f'''
W605_2.py:8:1: W605 [*] Invalid escape sequence: `\.`
|
6 | #: W605:2:1
7 | regex = f'''
8 | \.png$
| ^^ W605
9 | '''
|
= help: Add backslash to escape sequence
Fix
4 4 | regex = f'\.png$'
5 5 |
6 6 | #: W605:2:1
7 |-regex = f'''
7 |+regex = rf'''
8 8 | \.png$
9 9 | '''
10 10 |
W605_2.py:13:7: W605 [*] Invalid escape sequence: `\_`
|
11 | #: W605:2:6
12 | f(
13 | f'\_'
| ^^ W605
14 | )
|
= help: Add backslash to escape sequence
Fix
10 10 |
11 11 | #: W605:2:6
12 12 | f(
13 |- f'\_'
13 |+ rf'\_'
14 14 | )
15 15 |
16 16 | #: W605:4:6
W605_2.py:20:6: W605 [*] Invalid escape sequence: `\_`
|
18 | multi-line
19 | literal
20 | with \_ somewhere
| ^^ W605
21 | in the middle
22 | """
|
= help: Add backslash to escape sequence
Fix
14 14 | )
15 15 |
16 16 | #: W605:4:6
17 |-f"""
17 |+rf"""
18 18 | multi-line
19 19 | literal
20 20 | with \_ somewhere
W605_2.py:25:40: W605 [*] Invalid escape sequence: `\_`
|
24 | #: W605:1:38
25 | value = f'new line\nand invalid escape \_ here'
| ^^ W605
|
= help: Add backslash to escape sequence
Fix
22 22 | """
23 23 |
24 24 | #: W605:1:38
25 |-value = f'new line\nand invalid escape \_ here'
25 |+value = f'new line\nand invalid escape \\_ here'
26 26 |
27 27 |
28 28 | #: Okay
W605_2.py:43:13: W605 [*] Invalid escape sequence: `\_`
|
41 | ''' # noqa
42 |
43 | regex = f'\\\_'
| ^^ W605
44 | value = f'\{{1}}'
45 | value = f'\{1}'
|
= help: Add backslash to escape sequence
Fix
40 40 | \w
41 41 | ''' # noqa
42 42 |
43 |-regex = f'\\\_'
43 |+regex = f'\\\\_'
44 44 | value = f'\{{1}}'
45 45 | value = f'\{1}'
46 46 | value = f'{1:\}'
W605_2.py:44:11: W605 [*] Invalid escape sequence: `\{`
|
43 | regex = f'\\\_'
44 | value = f'\{{1}}'
| ^^ W605
45 | value = f'\{1}'
46 | value = f'{1:\}'
|
= help: Add backslash to escape sequence
Fix
41 41 | ''' # noqa
42 42 |
43 43 | regex = f'\\\_'
44 |-value = f'\{{1}}'
44 |+value = rf'\{{1}}'
45 45 | value = f'\{1}'
46 46 | value = f'{1:\}'
47 47 | value = f"{f"\{1}"}"
W605_2.py:45:11: W605 [*] Invalid escape sequence: `\{`
|
43 | regex = f'\\\_'
44 | value = f'\{{1}}'
45 | value = f'\{1}'
| ^^ W605
46 | value = f'{1:\}'
47 | value = f"{f"\{1}"}"
|
= help: Add backslash to escape sequence
Fix
42 42 |
43 43 | regex = f'\\\_'
44 44 | value = f'\{{1}}'
45 |-value = f'\{1}'
45 |+value = rf'\{1}'
46 46 | value = f'{1:\}'
47 47 | value = f"{f"\{1}"}"
48 48 | value = rf"{f"\{1}"}"
W605_2.py:46:14: W605 [*] Invalid escape sequence: `\}`
|
44 | value = f'\{{1}}'
45 | value = f'\{1}'
46 | value = f'{1:\}'
| ^^ W605
47 | value = f"{f"\{1}"}"
48 | value = rf"{f"\{1}"}"
|
= help: Add backslash to escape sequence
Fix
43 43 | regex = f'\\\_'
44 44 | value = f'\{{1}}'
45 45 | value = f'\{1}'
46 |-value = f'{1:\}'
46 |+value = rf'{1:\}'
47 47 | value = f"{f"\{1}"}"
48 48 | value = rf"{f"\{1}"}"
49 49 |
W605_2.py:47:14: W605 [*] Invalid escape sequence: `\{`
|
45 | value = f'\{1}'
46 | value = f'{1:\}'
47 | value = f"{f"\{1}"}"
| ^^ W605
48 | value = rf"{f"\{1}"}"
|
= help: Add backslash to escape sequence
Fix
44 44 | value = f'\{{1}}'
45 45 | value = f'\{1}'
46 46 | value = f'{1:\}'
47 |-value = f"{f"\{1}"}"
47 |+value = f"{rf"\{1}"}"
48 48 | value = rf"{f"\{1}"}"
49 49 |
50 50 | # Okay
W605_2.py:48:15: W605 [*] Invalid escape sequence: `\{`
|
46 | value = f'{1:\}'
47 | value = f"{f"\{1}"}"
48 | value = rf"{f"\{1}"}"
| ^^ W605
49 |
50 | # Okay
|
= help: Add backslash to escape sequence
Fix
45 45 | value = f'\{1}'
46 46 | value = f'{1:\}'
47 47 | value = f"{f"\{1}"}"
48 |-value = rf"{f"\{1}"}"
48 |+value = rf"{rf"\{1}"}"
49 49 |
50 50 | # Okay
51 51 | value = rf'\{{1}}'

View file

@ -1,7 +1,7 @@
use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix}; use ruff_diagnostics::{AlwaysFixableViolation, Diagnostic, Edit, Fix};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_ast::{Expr, PySourceType}; use ruff_python_ast::{self as ast, Expr, PySourceType};
use ruff_python_parser::{lexer, AsMode, StringKind, Tok}; use ruff_python_parser::{lexer, AsMode, Tok};
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextRange, TextSize}; use ruff_text_size::{Ranged, TextRange, TextSize};
@ -47,31 +47,54 @@ impl AlwaysFixableViolation for FStringMissingPlaceholders {
} }
} }
/// Find f-strings that don't contain any formatted values in an [`FString`]. /// Return an iterator containing a two-element tuple for each f-string part
fn find_useless_f_strings<'a>( /// in the given [`ExprFString`] expression.
expr: &'a Expr, ///
/// The first element of the tuple is the f-string prefix range, and the second
/// element is the entire f-string range. It returns an iterator because of the
/// possibility of multiple f-strings implicitly concatenated together.
///
/// For example,
///
/// ```python
/// f"first" rf"second"
/// # ^ ^ (prefix range)
/// # ^^^^^^^^ ^^^^^^^^^^ (token range)
/// ```
///
/// would return `[(0..1, 0..8), (10..11, 9..19)]`.
///
/// This function assumes that the given f-string expression is without any
/// placeholder expressions.
///
/// [`ExprFString`]: `ruff_python_ast::ExprFString`
fn fstring_prefix_and_tok_range<'a>(
fstring: &'a ast::ExprFString,
locator: &'a Locator, locator: &'a Locator,
source_type: PySourceType, source_type: PySourceType,
) -> impl Iterator<Item = (TextRange, TextRange)> + 'a { ) -> impl Iterator<Item = (TextRange, TextRange)> + 'a {
let contents = locator.slice(expr); let contents = locator.slice(fstring);
lexer::lex_starts_at(contents, source_type.as_mode(), expr.start()) let mut current_f_string_start = fstring.start();
lexer::lex_starts_at(contents, source_type.as_mode(), fstring.start())
.flatten() .flatten()
.filter_map(|(tok, range)| match tok { .filter_map(move |(tok, range)| match tok {
Tok::String { Tok::FStringStart => {
kind: StringKind::FString | StringKind::RawFString, current_f_string_start = range.start();
.. None
} => { }
let first_char = locator.slice(TextRange::at(range.start(), TextSize::from(1))); Tok::FStringEnd => {
let first_char =
locator.slice(TextRange::at(current_f_string_start, TextSize::from(1)));
// f"..." => f_position = 0 // f"..." => f_position = 0
// fr"..." => f_position = 0 // fr"..." => f_position = 0
// rf"..." => f_position = 1 // rf"..." => f_position = 1
let f_position = u32::from(!(first_char == "f" || first_char == "F")); let f_position = u32::from(!(first_char == "f" || first_char == "F"));
Some(( Some((
TextRange::at( TextRange::at(
range.start() + TextSize::from(f_position), current_f_string_start + TextSize::from(f_position),
TextSize::from(1), TextSize::from(1),
), ),
range, TextRange::new(current_f_string_start, range.end()),
)) ))
} }
_ => None, _ => None,
@ -79,13 +102,14 @@ fn find_useless_f_strings<'a>(
} }
/// F541 /// F541
pub(crate) fn f_string_missing_placeholders(expr: &Expr, values: &[Expr], checker: &mut Checker) { pub(crate) fn f_string_missing_placeholders(fstring: &ast::ExprFString, checker: &mut Checker) {
if !values if !fstring
.values
.iter() .iter()
.any(|value| matches!(value, Expr::FormattedValue(_))) .any(|value| matches!(value, Expr::FormattedValue(_)))
{ {
for (prefix_range, tok_range) in for (prefix_range, tok_range) in
find_useless_f_strings(expr, checker.locator(), checker.source_type) fstring_prefix_and_tok_range(fstring, checker.locator(), checker.source_type)
{ {
let mut diagnostic = Diagnostic::new(FStringMissingPlaceholders, tok_range); let mut diagnostic = Diagnostic::new(FStringMissingPlaceholders, tok_range);
if checker.patch(diagnostic.kind.rule()) { if checker.patch(diagnostic.kind.rule()) {

View file

@ -332,7 +332,7 @@ F541.py:40:3: F541 [*] f-string without any placeholders
40 |+"" "" 40 |+"" ""
41 41 | ''f"" 41 41 | ''f""
42 42 | (""f""r"") 42 42 | (""f""r"")
43 43 | 43 43 | f"{v:{f"0.2f"}}"
F541.py:41:3: F541 [*] f-string without any placeholders F541.py:41:3: F541 [*] f-string without any placeholders
| |
@ -341,6 +341,7 @@ F541.py:41:3: F541 [*] f-string without any placeholders
41 | ''f"" 41 | ''f""
| ^^^ F541 | ^^^ F541
42 | (""f""r"") 42 | (""f""r"")
43 | f"{v:{f"0.2f"}}"
| |
= help: Remove extraneous `f` prefix = help: Remove extraneous `f` prefix
@ -351,8 +352,8 @@ F541.py:41:3: F541 [*] f-string without any placeholders
41 |-''f"" 41 |-''f""
41 |+''"" 41 |+''""
42 42 | (""f""r"") 42 42 | (""f""r"")
43 43 | 43 43 | f"{v:{f"0.2f"}}"
44 44 | # To be fixed 44 44 | f"\{{x}}"
F541.py:42:4: F541 [*] f-string without any placeholders F541.py:42:4: F541 [*] f-string without any placeholders
| |
@ -360,8 +361,8 @@ F541.py:42:4: F541 [*] f-string without any placeholders
41 | ''f"" 41 | ''f""
42 | (""f""r"") 42 | (""f""r"")
| ^^^ F541 | ^^^ F541
43 | 43 | f"{v:{f"0.2f"}}"
44 | # To be fixed 44 | f"\{{x}}"
| |
= help: Remove extraneous `f` prefix = help: Remove extraneous `f` prefix
@ -371,8 +372,41 @@ F541.py:42:4: F541 [*] f-string without any placeholders
41 41 | ''f"" 41 41 | ''f""
42 |-(""f""r"") 42 |-(""f""r"")
42 |+("" ""r"") 42 |+("" ""r"")
43 43 | 43 43 | f"{v:{f"0.2f"}}"
44 44 | # To be fixed 44 44 | f"\{{x}}"
45 45 | # Error: f-string: single '}' is not allowed at line 41 column 8
F541.py:43:7: F541 [*] f-string without any placeholders
|
41 | ''f""
42 | (""f""r"")
43 | f"{v:{f"0.2f"}}"
| ^^^^^^^ F541
44 | f"\{{x}}"
|
= help: Remove extraneous `f` prefix
Fix
40 40 | ""f""
41 41 | ''f""
42 42 | (""f""r"")
43 |-f"{v:{f"0.2f"}}"
43 |+f"{v:{"0.2f"}}"
44 44 | f"\{{x}}"
F541.py:44:1: F541 [*] f-string without any placeholders
|
42 | (""f""r"")
43 | f"{v:{f"0.2f"}}"
44 | f"\{{x}}"
| ^^^^^^^^^ F541
|
= help: Remove extraneous `f` prefix
Fix
41 41 | ''f""
42 42 | (""f""r"")
43 43 | f"{v:{f"0.2f"}}"
44 |-f"\{{x}}"
44 |+"\{x}"

View file

@ -4,6 +4,7 @@ use ruff_diagnostics::AlwaysFixableViolation;
use ruff_diagnostics::Edit; use ruff_diagnostics::Edit;
use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix}; use ruff_diagnostics::{Diagnostic, DiagnosticKind, Fix};
use ruff_macros::{derive_message_formats, violation}; use ruff_macros::{derive_message_formats, violation};
use ruff_python_parser::Tok;
use ruff_source_file::Locator; use ruff_source_file::Locator;
/// ## What it does /// ## What it does
@ -173,10 +174,15 @@ impl AlwaysFixableViolation for InvalidCharacterZeroWidthSpace {
/// PLE2510, PLE2512, PLE2513, PLE2514, PLE2515 /// PLE2510, PLE2512, PLE2513, PLE2514, PLE2515
pub(crate) fn invalid_string_characters( pub(crate) fn invalid_string_characters(
diagnostics: &mut Vec<Diagnostic>, diagnostics: &mut Vec<Diagnostic>,
tok: &Tok,
range: TextRange, range: TextRange,
locator: &Locator, locator: &Locator,
) { ) {
let text = locator.slice(range); let text = match tok {
Tok::String { .. } => locator.slice(range),
Tok::FStringMiddle { value, .. } => value.as_str(),
_ => return,
};
for (column, match_) in text.match_indices(&['\x08', '\x1A', '\x1B', '\0', '\u{200b}']) { for (column, match_) in text.match_indices(&['\x08', '\x1A', '\x1B', '\0', '\u{200b}']) {
let c = match_.chars().next().unwrap(); let c = match_.chars().next().unwrap();

View file

@ -7,8 +7,7 @@ invalid_characters.py:15:6: PLE2510 [*] Invalid unescaped character backspace, u
14 | #foo = 'hi' 14 | #foo = 'hi'
15 | b = '' 15 | b = ''
| PLE2510 | PLE2510
16 | 16 | b = f''
17 | b_ok = '\\b'
| |
= help: Replace with escape sequence = help: Replace with escape sequence
@ -18,8 +17,45 @@ invalid_characters.py:15:6: PLE2510 [*] Invalid unescaped character backspace, u
14 14 | #foo = 'hi' 14 14 | #foo = 'hi'
15 |-b = '' 15 |-b = ''
15 |+b = '\b' 15 |+b = '\b'
16 16 | 16 16 | b = f''
17 17 | b_ok = '\\b' 17 17 |
18 18 | 18 18 | b_ok = '\\b'
invalid_characters.py:16:7: PLE2510 [*] Invalid unescaped character backspace, use "\b" instead
|
14 | #foo = 'hi'
15 | b = ''
16 | b = f''
| PLE2510
17 |
18 | b_ok = '\\b'
|
= help: Replace with escape sequence
Fix
13 13 | # (Pylint, "C3002") => Rule::UnnecessaryDirectLambdaCall,
14 14 | #foo = 'hi'
15 15 | b = ''
16 |-b = f''
16 |+b = f'\b'
17 17 |
18 18 | b_ok = '\\b'
19 19 | b_ok = f'\\b'
invalid_characters.py:55:21: PLE2510 [*] Invalid unescaped character backspace, use "\b" instead
|
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 |
55 | nested_fstrings = f'{f'{f''}'}'
| PLE2510
|
= help: Replace with escape sequence
Fix
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
55 |-nested_fstrings = f'{f'{f''}'}'
55 |+nested_fstrings = f'\b{f'{f''}'}'

View file

@ -1,25 +1,60 @@
--- ---
source: crates/ruff_linter/src/rules/pylint/mod.rs source: crates/ruff_linter/src/rules/pylint/mod.rs
--- ---
invalid_characters.py:21:12: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead invalid_characters.py:24:12: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead
| |
19 | cr_ok = '\\r' 22 | cr_ok = f'\\r'
20 | 23 |
21 | sub = 'sub ' 24 | sub = 'sub '
| PLE2512 | PLE2512
22 | 25 | sub = f'sub '
23 | sub_ok = '\x1a'
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
18 18 | 21 21 | cr_ok = '\\r'
19 19 | cr_ok = '\\r' 22 22 | cr_ok = f'\\r'
20 20 | 23 23 |
21 |-sub = 'sub ' 24 |-sub = 'sub '
21 |+sub = 'sub \x1A' 24 |+sub = 'sub \x1A'
22 22 | 25 25 | sub = f'sub '
23 23 | sub_ok = '\x1a' 26 26 |
24 24 | 27 27 | sub_ok = '\x1a'
invalid_characters.py:25:13: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead
|
24 | sub = 'sub '
25 | sub = f'sub '
| PLE2512
26 |
27 | sub_ok = '\x1a'
|
= help: Replace with escape sequence
Fix
22 22 | cr_ok = f'\\r'
23 23 |
24 24 | sub = 'sub '
25 |-sub = f'sub '
25 |+sub = f'sub \x1A'
26 26 |
27 27 | sub_ok = '\x1a'
28 28 | sub_ok = f'\x1a'
invalid_characters.py:55:25: PLE2512 [*] Invalid unescaped character SUB, use "\x1A" instead
|
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 |
55 | nested_fstrings = f'{f'{f''}'}'
| PLE2512
|
= help: Replace with escape sequence
Fix
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
55 |-nested_fstrings = f'{f'{f''}'}'
55 |+nested_fstrings = f'{f'\x1A{f''}'}'

View file

@ -1,25 +1,60 @@
--- ---
source: crates/ruff_linter/src/rules/pylint/mod.rs source: crates/ruff_linter/src/rules/pylint/mod.rs
--- ---
invalid_characters.py:25:16: PLE2513 [*] Invalid unescaped character ESC, use "\x1B" instead invalid_characters.py:30:16: PLE2513 [*] Invalid unescaped character ESC, use "\x1B" instead
| |
23 | sub_ok = '\x1a' 28 | sub_ok = f'\x1a'
24 | 29 |
25 | esc = 'esc esc ' 30 | esc = 'esc esc '
| PLE2513 | PLE2513
26 | 31 | esc = f'esc esc '
27 | esc_ok = '\x1b'
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
22 22 | 27 27 | sub_ok = '\x1a'
23 23 | sub_ok = '\x1a' 28 28 | sub_ok = f'\x1a'
24 24 | 29 29 |
25 |-esc = 'esc esc ' 30 |-esc = 'esc esc '
25 |+esc = 'esc esc \x1B' 30 |+esc = 'esc esc \x1B'
26 26 | 31 31 | esc = f'esc esc '
27 27 | esc_ok = '\x1b' 32 32 |
28 28 | 33 33 | esc_ok = '\x1b'
invalid_characters.py:31:17: PLE2513 [*] Invalid unescaped character ESC, use "\x1B" instead
|
30 | esc = 'esc esc '
31 | esc = f'esc esc '
| PLE2513
32 |
33 | esc_ok = '\x1b'
|
= help: Replace with escape sequence
Fix
28 28 | sub_ok = f'\x1a'
29 29 |
30 30 | esc = 'esc esc '
31 |-esc = f'esc esc '
31 |+esc = f'esc esc \x1B'
32 32 |
33 33 | esc_ok = '\x1b'
34 34 | esc_ok = f'\x1b'
invalid_characters.py:55:29: PLE2513 [*] Invalid unescaped character ESC, use "\x1B" instead
|
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 |
55 | nested_fstrings = f'{f'{f''}'}'
| PLE2513
|
= help: Replace with escape sequence
Fix
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
55 |-nested_fstrings = f'{f'{f''}'}'
55 |+nested_fstrings = f'{f'{f'\x1B'}'}'

View file

@ -1,73 +1,165 @@
--- ---
source: crates/ruff_linter/src/rules/pylint/mod.rs source: crates/ruff_linter/src/rules/pylint/mod.rs
--- ---
invalid_characters.py:34:13: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead invalid_characters.py:44:13: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
| |
32 | nul_ok = '\0' 42 | nul_ok = f'\0'
33 | 43 |
34 | zwsp = 'zerowidth' 44 | zwsp = 'zerowidth'
| PLE2515 | PLE2515
35 | 45 | zwsp = f'zerowidth'
36 | zwsp_ok = '\u200b'
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
31 31 | 41 41 | nul_ok = '\0'
32 32 | nul_ok = '\0' 42 42 | nul_ok = f'\0'
33 33 | 43 43 |
34 |-zwsp = 'zerowidth' 44 |-zwsp = 'zerowidth'
34 |+zwsp = 'zero\u200bwidth' 44 |+zwsp = 'zero\u200bwidth'
35 35 | 45 45 | zwsp = f'zerowidth'
36 36 | zwsp_ok = '\u200b' 46 46 |
37 37 | 47 47 | zwsp_ok = '\u200b'
invalid_characters.py:38:36: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead invalid_characters.py:45:14: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
| |
36 | zwsp_ok = '\u200b' 44 | zwsp = 'zerowidth'
37 | 45 | zwsp = f'zerowidth'
38 | zwsp_after_multibyte_character = "ಫ​" | PLE2515
46 |
47 | zwsp_ok = '\u200b'
|
= help: Replace with escape sequence
Fix
42 42 | nul_ok = f'\0'
43 43 |
44 44 | zwsp = 'zerowidth'
45 |-zwsp = f'zerowidth'
45 |+zwsp = f'zero\u200bwidth'
46 46 |
47 47 | zwsp_ok = '\u200b'
48 48 | zwsp_ok = f'\u200b'
invalid_characters.py:50:36: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
|
48 | zwsp_ok = f'\u200b'
49 |
50 | zwsp_after_multibyte_character = "ಫ​"
| PLE2515 | PLE2515
39 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 51 | zwsp_after_multibyte_character = f"ಫ​"
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
35 35 | 47 47 | zwsp_ok = '\u200b'
36 36 | zwsp_ok = '\u200b' 48 48 | zwsp_ok = f'\u200b'
37 37 | 49 49 |
38 |-zwsp_after_multibyte_character = "ಫ​" 50 |-zwsp_after_multibyte_character = "ಫ​"
38 |+zwsp_after_multibyte_character = "ಫ\u200b" 50 |+zwsp_after_multibyte_character = "ಫ\u200b"
39 39 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 51 51 | zwsp_after_multibyte_character = f"ಫ​"
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
invalid_characters.py:39:60: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead invalid_characters.py:51:37: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
| |
38 | zwsp_after_multibyte_character = "ಫ​" 50 | zwsp_after_multibyte_character = "ಫ​"
39 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 51 | zwsp_after_multibyte_character = f"ಫ​"
| PLE2515
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
|
= help: Replace with escape sequence
Fix
48 48 | zwsp_ok = f'\u200b'
49 49 |
50 50 | zwsp_after_multibyte_character = "ಫ​"
51 |-zwsp_after_multibyte_character = f"ಫ​"
51 |+zwsp_after_multibyte_character = f"ಫ\u200b"
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
invalid_characters.py:52:60: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
|
50 | zwsp_after_multibyte_character = "ಫ​"
51 | zwsp_after_multibyte_character = f"ಫ​"
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| PLE2515 | PLE2515
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
36 36 | zwsp_ok = '\u200b' 49 49 |
37 37 | 50 50 | zwsp_after_multibyte_character = "ಫ​"
38 38 | zwsp_after_multibyte_character = "ಫ​" 51 51 | zwsp_after_multibyte_character = f"ಫ​"
39 |-zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 52 |-zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
39 |+zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b" 52 |+zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b"
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
55 55 | nested_fstrings = f'{f'{f''}'}'
invalid_characters.py:39:61: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead invalid_characters.py:52:61: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
| |
38 | zwsp_after_multibyte_character = "ಫ​" 50 | zwsp_after_multibyte_character = "ಫ​"
39 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 51 | zwsp_after_multibyte_character = f"ಫ​"
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| PLE2515 | PLE2515
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| |
= help: Replace with escape sequence = help: Replace with escape sequence
Fix Fix
36 36 | zwsp_ok = '\u200b' 49 49 |
37 37 | 50 50 | zwsp_after_multibyte_character = "ಫ​"
38 38 | zwsp_after_multibyte_character = "ಫ​" 51 51 | zwsp_after_multibyte_character = f"ಫ​"
39 |-zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ " 52 |-zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
39 |+zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b" 52 |+zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b"
53 53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
54 54 |
55 55 | nested_fstrings = f'{f'{f''}'}'
invalid_characters.py:53:61: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
|
51 | zwsp_after_multibyte_character = f"ಫ​"
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| PLE2515
54 |
55 | nested_fstrings = f'{f'{f''}'}'
|
= help: Replace with escape sequence
Fix
50 50 | zwsp_after_multibyte_character = "ಫ​"
51 51 | zwsp_after_multibyte_character = f"ಫ​"
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 |-zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 |+zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b"
54 54 |
55 55 | nested_fstrings = f'{f'{f''}'}'
invalid_characters.py:53:62: PLE2515 [*] Invalid unescaped character zero-width-space, use "\u200B" instead
|
51 | zwsp_after_multibyte_character = f"ಫ​"
52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 | zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
| PLE2515
54 |
55 | nested_fstrings = f'{f'{f''}'}'
|
= help: Replace with escape sequence
Fix
50 50 | zwsp_after_multibyte_character = "ಫ​"
51 51 | zwsp_after_multibyte_character = f"ಫ​"
52 52 | zwsp_after_multicharacter_grapheme_cluster = "ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 |-zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ "
53 |+zwsp_after_multicharacter_grapheme_cluster = f"ಫ್ರಾನ್ಸಿಸ್ಕೊ \u200b"
54 54 |
55 55 | nested_fstrings = f'{f'{f''}'}'

View file

@ -123,6 +123,7 @@ impl Violation for AmbiguousUnicodeCharacterComment {
} }
} }
/// RUF001, RUF002, RUF003
pub(crate) fn ambiguous_unicode_character( pub(crate) fn ambiguous_unicode_character(
diagnostics: &mut Vec<Diagnostic>, diagnostics: &mut Vec<Diagnostic>,
locator: &Locator, locator: &Locator,

View file

@ -49,6 +49,8 @@ confusables.py:31:6: RUF001 String contains ambiguous `Р` (CYRILLIC CAPITAL LET
30 | # boundary" (whitespace) that it itself ambiguous. 30 | # boundary" (whitespace) that it itself ambiguous.
31 | x = "Р усский" 31 | x = "Р усский"
| ^ RUF001 | ^ RUF001
32 |
33 | # Same test cases as above but using f-strings instead:
| |
confusables.py:31:7: RUF001 String contains ambiguous ` ` (EN QUAD). Did you mean ` ` (SPACE)? confusables.py:31:7: RUF001 String contains ambiguous ` ` (EN QUAD). Did you mean ` ` (SPACE)?
@ -57,6 +59,100 @@ confusables.py:31:7: RUF001 String contains ambiguous ` ` (EN QUAD). Did you m
30 | # boundary" (whitespace) that it itself ambiguous. 30 | # boundary" (whitespace) that it itself ambiguous.
31 | x = "Р усский" 31 | x = "Р усский"
| ^ RUF001 | ^ RUF001
32 |
33 | # Same test cases as above but using f-strings instead:
|
confusables.py:34:7: RUF001 String contains ambiguous `𝐁` (MATHEMATICAL BOLD CAPITAL B). Did you mean `B` (LATIN CAPITAL LETTER B)?
|
33 | # Same test cases as above but using f-strings instead:
34 | x = f"𝐁ad string"
| ^ RUF001
35 | x = f""
36 | x = f"Русский"
|
confusables.py:37:11: RUF001 String contains ambiguous `α` (GREEK SMALL LETTER ALPHA). Did you mean `a` (LATIN SMALL LETTER A)?
|
35 | x = f""
36 | x = f"Русский"
37 | x = f"βα Bαd"
| ^ RUF001
38 | x = f"Р усский"
|
confusables.py:38:7: RUF001 String contains ambiguous `Р` (CYRILLIC CAPITAL LETTER ER). Did you mean `P` (LATIN CAPITAL LETTER P)?
|
36 | x = f"Русский"
37 | x = f"βα Bαd"
38 | x = f"Р усский"
| ^ RUF001
39 |
40 | # Nested f-strings
|
confusables.py:38:8: RUF001 String contains ambiguous ` ` (EN QUAD). Did you mean ` ` (SPACE)?
|
36 | x = f"Русский"
37 | x = f"βα Bαd"
38 | x = f"Р усский"
| ^ RUF001
39 |
40 | # Nested f-strings
|
confusables.py:41:7: RUF001 String contains ambiguous `𝐁` (MATHEMATICAL BOLD CAPITAL B). Did you mean `B` (LATIN CAPITAL LETTER B)?
|
40 | # Nested f-strings
41 | x = f"𝐁ad string {f" {f"Р усский"}"}"
| ^ RUF001
42 |
43 | # Comments inside f-strings
|
confusables.py:41:21: RUF001 String contains ambiguous ` ` (EN QUAD). Did you mean ` ` (SPACE)?
|
40 | # Nested f-strings
41 | x = f"𝐁ad string {f" {f"Р усский"}"}"
| ^ RUF001
42 |
43 | # Comments inside f-strings
|
confusables.py:41:25: RUF001 String contains ambiguous `Р` (CYRILLIC CAPITAL LETTER ER). Did you mean `P` (LATIN CAPITAL LETTER P)?
|
40 | # Nested f-strings
41 | x = f"𝐁ad string {f" {f"Р усский"}"}"
| ^ RUF001
42 |
43 | # Comments inside f-strings
|
confusables.py:41:26: RUF001 String contains ambiguous ` ` (EN QUAD). Did you mean ` ` (SPACE)?
|
40 | # Nested f-strings
41 | x = f"𝐁ad string {f" {f"Р усский"}"}"
| ^ RUF001
42 |
43 | # Comments inside f-strings
|
confusables.py:44:68: RUF003 Comment contains ambiguous `` (FULLWIDTH RIGHT PARENTHESIS). Did you mean `)` (RIGHT PARENTHESIS)?
|
43 | # Comments inside f-strings
44 | x = f"string { # And here's a comment with an unusual parenthesis:
| ^^ RUF003
45 | # And here's a comment with a greek alpha:
46 | foo # And here's a comment with an unusual punctuation mark:
|
confusables.py:46:62: RUF003 Comment contains ambiguous `` (PHILIPPINE SINGLE PUNCTUATION). Did you mean `/` (SOLIDUS)?
|
44 | x = f"string { # And here's a comment with an unusual parenthesis:
45 | # And here's a comment with a greek alpha:
46 | foo # And here's a comment with an unusual punctuation mark:
| ^ RUF003
47 | }"
| |

View file

@ -90,6 +90,13 @@ impl PythonVersion {
} }
minimum_version minimum_version
} }
/// Return `true` if the current version supports [PEP 701].
///
/// [PEP 701]: https://peps.python.org/pep-0701/
pub fn supports_pep701(self) -> bool {
self >= Self::Py312
}
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, CacheKey, is_macro::Is)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, CacheKey, is_macro::Is)]

View file

@ -2600,6 +2600,14 @@ impl Constant {
_ => false, _ => false,
} }
} }
/// Returns `true` if the constant is a string constant that is a unicode string (i.e., `u"..."`).
pub fn is_unicode_string(&self) -> bool {
match self {
Constant::Str(value) => value.unicode,
_ => false,
}
}
} }
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -2620,6 +2628,16 @@ impl Deref for StringConstant {
} }
} }
impl From<String> for StringConstant {
fn from(value: String) -> StringConstant {
Self {
value,
unicode: false,
implicit_concatenated: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct BytesConstant { pub struct BytesConstant {
/// The bytes value as resolved by the parser (i.e., without quotes, or escape sequences, or /// The bytes value as resolved by the parser (i.e., without quotes, or escape sequences, or
@ -2636,6 +2654,15 @@ impl Deref for BytesConstant {
} }
} }
impl From<Vec<u8>> for BytesConstant {
fn from(value: Vec<u8>) -> BytesConstant {
Self {
value,
implicit_concatenated: false,
}
}
}
impl From<Vec<u8>> for Constant { impl From<Vec<u8>> for Constant {
fn from(value: Vec<u8>) -> Constant { fn from(value: Vec<u8>) -> Constant {
Self::Bytes(BytesConstant { Self::Bytes(BytesConstant {
@ -3207,6 +3234,12 @@ pub struct ParenthesizedExpr {
/// The underlying expression. /// The underlying expression.
pub expr: Expr, pub expr: Expr,
} }
impl ParenthesizedExpr {
/// Returns `true` if the expression is may be parenthesized.
pub fn is_parenthesized(&self) -> bool {
self.range != self.expr.range()
}
}
impl Ranged for ParenthesizedExpr { impl Ranged for ParenthesizedExpr {
fn range(&self) -> TextRange { fn range(&self) -> TextRange {
self.range self.range

View file

@ -130,7 +130,7 @@ fn function_type_parameters() {
fn trace_preorder_visitation(source: &str) -> String { fn trace_preorder_visitation(source: &str) -> String {
let tokens = lex(source, Mode::Module); let tokens = lex(source, Mode::Module);
let parsed = parse_tokens(tokens, Mode::Module, "test.py").unwrap(); let parsed = parse_tokens(tokens, source, Mode::Module, "test.py").unwrap();
let mut visitor = RecordVisitor::default(); let mut visitor = RecordVisitor::default();
visitor.visit_mod(&parsed); visitor.visit_mod(&parsed);

View file

@ -131,7 +131,7 @@ fn function_type_parameters() {
fn trace_visitation(source: &str) -> String { fn trace_visitation(source: &str) -> String {
let tokens = lex(source, Mode::Module); let tokens = lex(source, Mode::Module);
let parsed = parse_tokens(tokens, Mode::Module, "test.py").unwrap(); let parsed = parse_tokens(tokens, source, Mode::Module, "test.py").unwrap();
let mut visitor = RecordVisitor::default(); let mut visitor = RecordVisitor::default();
walk_module(&mut visitor, &parsed); walk_module(&mut visitor, &parsed);

View file

@ -55,6 +55,9 @@ fn detect_quote(tokens: &[LexResult], locator: &Locator) -> Quote {
triple_quoted: false, triple_quoted: false,
.. ..
} => Some(*range), } => Some(*range),
// No need to check if it's triple-quoted as f-strings cannot be used
// as docstrings.
Tok::FStringStart => Some(*range),
_ => None, _ => None,
}); });
@ -275,6 +278,14 @@ class FormFeedIndent:
Quote::Single Quote::Single
); );
let contents = r#"x = f'1'"#;
let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect();
assert_eq!(
Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Single
);
let contents = r#"x = "1""#; let contents = r#"x = "1""#;
let locator = Locator::new(contents); let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect(); let tokens: Vec<_> = lex(contents, Mode::Module).collect();
@ -283,6 +294,14 @@ class FormFeedIndent:
Quote::Double Quote::Double
); );
let contents = r#"x = f"1""#;
let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect();
assert_eq!(
Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Double
);
let contents = r#"s = "It's done.""#; let contents = r#"s = "It's done.""#;
let locator = Locator::new(contents); let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect(); let tokens: Vec<_> = lex(contents, Mode::Module).collect();
@ -328,6 +347,41 @@ a = "v"
Stylist::from_tokens(&tokens, &locator).quote(), Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Double Quote::Double
); );
// Detect from f-string appearing after docstring
let contents = r#"
"""Module docstring."""
a = f'v'
"#;
let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect();
assert_eq!(
Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Single
);
let contents = r#"
'''Module docstring.'''
a = f"v"
"#;
let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect();
assert_eq!(
Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Double
);
let contents = r#"
f'''Module docstring.'''
"#;
let locator = Locator::new(contents);
let tokens: Vec<_> = lex(contents, Mode::Module).collect();
assert_eq!(
Stylist::from_tokens(&tokens, &locator).quote(),
Quote::Single
);
} }
#[test] #[test]

View file

@ -43,8 +43,8 @@ pub fn format_and_debug_print(source: &str, cli: &Cli, source_type: &Path) -> Re
.map_err(|err| format_err!("Source contains syntax errors {err:?}"))?; .map_err(|err| format_err!("Source contains syntax errors {err:?}"))?;
// Parse the AST. // Parse the AST.
let module = let module = parse_ok_tokens(tokens, source, Mode::Module, "<filename>")
parse_ok_tokens(tokens, Mode::Module, "<filename>").context("Syntax error in input")?; .context("Syntax error in input")?;
let options = PyFormatOptions::from_extension(source_type); let options = PyFormatOptions::from_extension(source_type);

View file

@ -567,7 +567,7 @@ mod tests {
let source_code = SourceCode::new(source); let source_code = SourceCode::new(source);
let (tokens, comment_ranges) = let (tokens, comment_ranges) =
tokens_and_ranges(source).expect("Expect source to be valid Python"); tokens_and_ranges(source).expect("Expect source to be valid Python");
let parsed = parse_ok_tokens(tokens, Mode::Module, "test.py") let parsed = parse_ok_tokens(tokens, source, Mode::Module, "test.py")
.expect("Expect source to be valid Python"); .expect("Expect source to be valid Python");
CommentsTestCase { CommentsTestCase {

View file

@ -139,7 +139,7 @@ impl<'a> FormatString<'a> {
impl<'a> Format<PyFormatContext<'_>> for FormatString<'a> { impl<'a> Format<PyFormatContext<'_>> for FormatString<'a> {
fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> { fn fmt(&self, f: &mut PyFormatter) -> FormatResult<()> {
let locator = f.context().locator(); let locator = f.context().locator();
match self.layout { let result = match self.layout {
StringLayout::Default => { StringLayout::Default => {
if self.string.is_implicit_concatenated() { if self.string.is_implicit_concatenated() {
in_parentheses_only_group(&FormatStringContinuation::new(self.string)).fmt(f) in_parentheses_only_group(&FormatStringContinuation::new(self.string)).fmt(f)
@ -162,7 +162,73 @@ impl<'a> Format<PyFormatContext<'_>> for FormatString<'a> {
StringLayout::ImplicitConcatenatedStringInBinaryLike => { StringLayout::ImplicitConcatenatedStringInBinaryLike => {
FormatStringContinuation::new(self.string).fmt(f) FormatStringContinuation::new(self.string).fmt(f)
} }
};
// TODO(dhruvmanila): With PEP 701, comments can be inside f-strings.
// This is to mark all of those comments as formatted but we need to
// figure out how to handle them. Note that this needs to be done only
// after the f-string is formatted, so only for all the non-formatted
// comments.
if let AnyString::FString(fstring) = self.string {
let comments = f.context().comments();
fstring.values.iter().for_each(|value| {
comments.mark_verbatim_node_comments_formatted(value.into());
});
} }
result
}
}
/// A builder for the f-string range.
///
/// For now, this is limited to the outermost f-string and doesn't support
/// nested f-strings.
#[derive(Debug, Default)]
struct FStringRangeBuilder {
start_location: TextSize,
end_location: TextSize,
nesting: u32,
}
impl FStringRangeBuilder {
fn visit_token(&mut self, token: &Tok, range: TextRange) {
match token {
Tok::FStringStart => {
if self.nesting == 0 {
self.start_location = range.start();
}
self.nesting += 1;
}
Tok::FStringEnd => {
// We can assume that this will never overflow because we know
// that the program once parsed to a valid AST which means that
// the start and end tokens for f-strings are balanced.
self.nesting -= 1;
if self.nesting == 0 {
self.end_location = range.end();
}
}
_ => {}
}
}
/// Returns `true` if the lexer is currently inside of a f-string.
///
/// It'll return `false` once the `FStringEnd` token for the outermost
/// f-string is visited.
const fn in_fstring(&self) -> bool {
self.nesting > 0
}
/// Returns the complete range of the previously visited f-string.
///
/// This method should only be called once the lexer is outside of any
/// f-string otherwise it might return an invalid range.
///
/// It doesn't consume the builder because there can be multiple f-strings
/// throughout the source code.
fn finish(&self) -> TextRange {
debug_assert!(!self.in_fstring());
TextRange::new(self.start_location, self.end_location)
} }
} }
@ -195,6 +261,10 @@ impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
// because this is a black preview style. // because this is a black preview style.
let lexer = lex_starts_at(string_content, Mode::Expression, string_range.start()); let lexer = lex_starts_at(string_content, Mode::Expression, string_range.start());
// The lexer emits multiple tokens for a single f-string literal. Each token
// will have it's own range but we require the complete range of the f-string.
let mut fstring_range_builder = FStringRangeBuilder::default();
let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space()); let mut joiner = f.join_with(in_parentheses_only_soft_line_break_or_space());
for token in lexer { for token in lexer {
@ -226,8 +296,31 @@ impl Format<PyFormatContext<'_>> for FormatStringContinuation<'_> {
} }
}; };
fstring_range_builder.visit_token(&token, token_range);
// We need to ignore all the tokens within the f-string as there can
// be `String` tokens inside it as well. For example,
//
// ```python
// f"foo {'bar'} foo"
// # ^^^^^
// # Ignore any logic for this `String` token
// ```
//
// Here, we're interested in the complete f-string, not the individual
// tokens inside it.
if fstring_range_builder.in_fstring() {
continue;
}
match token { match token {
Tok::String { .. } => { Tok::String { .. } | Tok::FStringEnd => {
let token_range = if token.is_f_string_end() {
fstring_range_builder.finish()
} else {
token_range
};
// ```python // ```python
// ( // (
// "a" // "a"
@ -346,11 +439,7 @@ impl StringPart {
} }
}; };
let normalized = normalize_string( let normalized = normalize_string(locator.slice(self.content_range), quotes, self.prefix);
locator.slice(self.content_range),
quotes,
self.prefix.is_raw_string(),
);
NormalizedString { NormalizedString {
prefix: self.prefix, prefix: self.prefix,
@ -442,6 +531,10 @@ impl StringPrefix {
pub(super) const fn is_raw_string(self) -> bool { pub(super) const fn is_raw_string(self) -> bool {
self.contains(StringPrefix::RAW) || self.contains(StringPrefix::RAW_UPPER) self.contains(StringPrefix::RAW) || self.contains(StringPrefix::RAW_UPPER)
} }
pub(super) const fn is_fstring(self) -> bool {
self.contains(StringPrefix::F_STRING)
}
} }
impl Format<PyFormatContext<'_>> for StringPrefix { impl Format<PyFormatContext<'_>> for StringPrefix {
@ -681,7 +774,7 @@ impl Format<PyFormatContext<'_>> for StringQuotes {
/// with the provided [`StringQuotes`] style. /// with the provided [`StringQuotes`] style.
/// ///
/// Returns the normalized string and whether it contains new lines. /// Returns the normalized string and whether it contains new lines.
fn normalize_string(input: &str, quotes: StringQuotes, is_raw: bool) -> Cow<str> { fn normalize_string(input: &str, quotes: StringQuotes, prefix: StringPrefix) -> Cow<str> {
// The normalized string if `input` is not yet normalized. // The normalized string if `input` is not yet normalized.
// `output` must remain empty if `input` is already normalized. // `output` must remain empty if `input` is already normalized.
let mut output = String::new(); let mut output = String::new();
@ -693,14 +786,30 @@ fn normalize_string(input: &str, quotes: StringQuotes, is_raw: bool) -> Cow<str>
let preferred_quote = style.as_char(); let preferred_quote = style.as_char();
let opposite_quote = style.invert().as_char(); let opposite_quote = style.invert().as_char();
let mut chars = input.char_indices(); let mut chars = input.char_indices().peekable();
let is_raw = prefix.is_raw_string();
let is_fstring = prefix.is_fstring();
let mut formatted_value_nesting = 0u32;
while let Some((index, c)) = chars.next() { while let Some((index, c)) = chars.next() {
if is_fstring && matches!(c, '{' | '}') {
if chars.peek().copied().is_some_and(|(_, next)| next == c) {
// Skip over the second character of the double braces
chars.next();
} else if c == '{' {
formatted_value_nesting += 1;
} else {
// Safe to assume that `c == '}'` here because of the matched pattern above
formatted_value_nesting = formatted_value_nesting.saturating_sub(1);
}
continue;
}
if c == '\r' { if c == '\r' {
output.push_str(&input[last_index..index]); output.push_str(&input[last_index..index]);
// Skip over the '\r' character, keep the `\n` // Skip over the '\r' character, keep the `\n`
if input.as_bytes().get(index + 1).copied() == Some(b'\n') { if chars.peek().copied().is_some_and(|(_, next)| next == '\n') {
chars.next(); chars.next();
} }
// Replace the `\r` with a `\n` // Replace the `\r` with a `\n`
@ -711,9 +820,9 @@ fn normalize_string(input: &str, quotes: StringQuotes, is_raw: bool) -> Cow<str>
last_index = index + '\r'.len_utf8(); last_index = index + '\r'.len_utf8();
} else if !quotes.triple && !is_raw { } else if !quotes.triple && !is_raw {
if c == '\\' { if c == '\\' {
if let Some(next) = input.as_bytes().get(index + 1).copied().map(char::from) { if let Some((_, next)) = chars.peek().copied() {
#[allow(clippy::if_same_then_else)] #[allow(clippy::if_same_then_else)]
if next == opposite_quote { if next == opposite_quote && formatted_value_nesting == 0 {
// Remove the escape by ending before the backslash and starting again with the quote // Remove the escape by ending before the backslash and starting again with the quote
chars.next(); chars.next();
output.push_str(&input[last_index..index]); output.push_str(&input[last_index..index]);
@ -726,7 +835,7 @@ fn normalize_string(input: &str, quotes: StringQuotes, is_raw: bool) -> Cow<str>
chars.next(); chars.next();
} }
} }
} else if c == preferred_quote { } else if c == preferred_quote && formatted_value_nesting == 0 {
// Escape the quote // Escape the quote
output.push_str(&input[last_index..index]); output.push_str(&input[last_index..index]);
output.push('\\'); output.push('\\');

View file

@ -127,7 +127,7 @@ pub fn format_module_source(
options: PyFormatOptions, options: PyFormatOptions,
) -> Result<Printed, FormatModuleError> { ) -> Result<Printed, FormatModuleError> {
let (tokens, comment_ranges) = tokens_and_ranges(source)?; let (tokens, comment_ranges) = tokens_and_ranges(source)?;
let module = parse_ok_tokens(tokens, Mode::Module, "<filename>")?; let module = parse_ok_tokens(tokens, source, Mode::Module, "<filename>")?;
let formatted = format_module_ast(&module, &comment_ranges, source, options)?; let formatted = format_module_ast(&module, &comment_ranges, source, options)?;
Ok(formatted.print()?) Ok(formatted.print()?)
} }
@ -213,7 +213,7 @@ def main() -> None:
// Parse the AST. // Parse the AST.
let source_path = "code_inline.py"; let source_path = "code_inline.py";
let module = parse_ok_tokens(tokens, Mode::Module, source_path).unwrap(); let module = parse_ok_tokens(tokens, source, Mode::Module, source_path).unwrap();
let options = PyFormatOptions::from_extension(Path::new(source_path)); let options = PyFormatOptions::from_extension(Path::new(source_path));
let formatted = format_module_ast(&module, &comment_ranges, source, options).unwrap(); let formatted = format_module_ast(&module, &comment_ranges, source, options).unwrap();

View file

@ -0,0 +1,95 @@
use std::collections::BTreeMap;
use ruff_python_parser::Tok;
use ruff_text_size::{TextRange, TextSize};
/// Stores the ranges of all f-strings in a file sorted by [`TextRange::start`].
/// There can be multiple overlapping ranges for nested f-strings.
#[derive(Debug)]
pub struct FStringRanges {
raw: BTreeMap<TextSize, TextRange>,
}
impl FStringRanges {
/// Return the [`TextRange`] of the innermost f-string at the given offset.
pub fn innermost(&self, offset: TextSize) -> Option<TextRange> {
self.raw
.range(..=offset)
.rev()
.find(|(_, range)| range.contains(offset))
.map(|(_, range)| *range)
}
/// Return the [`TextRange`] of the outermost f-string at the given offset.
pub fn outermost(&self, offset: TextSize) -> Option<TextRange> {
// Explanation of the algorithm:
//
// ```python
// # v
// f"normal" f"another" f"first {f"second {f"third"} second"} first"
// # ^^(1)^^^
// # ^^^^^^^^^^^^(2)^^^^^^^^^^^^
// # ^^^^^^^^^^^^^^^^^^^^^(3)^^^^^^^^^^^^^^^^^^^^
// # ^^^(4)^^^^
// # ^^^(5)^^^
// ```
//
// The offset is marked with a `v` and the ranges are numbered in the order
// they are yielded by the iterator in the reverse order. The algorithm
// works as follows:
// 1. Skip all ranges that don't contain the offset (1).
// 2. Take all ranges that contain the offset (2, 3).
// 3. Stop taking ranges when the offset is no longer contained.
// 4. Take the last range that contained the offset (3, the outermost).
self.raw
.range(..=offset)
.rev()
.skip_while(|(_, range)| !range.contains(offset))
.take_while(|(_, range)| range.contains(offset))
.last()
.map(|(_, range)| *range)
}
/// Returns an iterator over all f-string [`TextRange`] sorted by their
/// start location.
///
/// For nested f-strings, the outermost f-string is yielded first, moving
/// inwards with each iteration.
#[inline]
pub fn values(&self) -> impl Iterator<Item = &TextRange> + '_ {
self.raw.values()
}
/// Returns the number of f-string ranges stored.
#[inline]
pub fn len(&self) -> usize {
self.raw.len()
}
}
#[derive(Default)]
pub(crate) struct FStringRangesBuilder {
start_locations: Vec<TextSize>,
raw: BTreeMap<TextSize, TextRange>,
}
impl FStringRangesBuilder {
pub(crate) fn visit_token(&mut self, token: &Tok, range: TextRange) {
match token {
Tok::FStringStart => {
self.start_locations.push(range.start());
}
Tok::FStringEnd => {
if let Some(start) = self.start_locations.pop() {
self.raw.insert(start, TextRange::new(start, range.end()));
}
}
_ => {}
}
}
pub(crate) fn finish(self) -> FStringRanges {
debug_assert!(self.start_locations.is_empty());
FStringRanges { raw: self.raw }
}
}

View file

@ -1,25 +1,26 @@
//! Struct used to index source code, to enable efficient lookup of tokens that //! Struct used to index source code, to enable efficient lookup of tokens that
//! are omitted from the AST (e.g., commented lines). //! are omitted from the AST (e.g., commented lines).
use crate::CommentRangesBuilder;
use ruff_python_ast::Stmt; use ruff_python_ast::Stmt;
use ruff_python_parser::lexer::LexResult; use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::{StringKind, Tok}; use ruff_python_parser::Tok;
use ruff_python_trivia::{ use ruff_python_trivia::{
has_leading_content, has_trailing_content, is_python_whitespace, CommentRanges, has_leading_content, has_trailing_content, is_python_whitespace, CommentRanges,
}; };
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::{Ranged, TextRange, TextSize}; use ruff_text_size::{Ranged, TextRange, TextSize};
use crate::fstring_ranges::{FStringRanges, FStringRangesBuilder};
use crate::CommentRangesBuilder;
pub struct Indexer { pub struct Indexer {
comment_ranges: CommentRanges, comment_ranges: CommentRanges,
/// Stores the start offset of continuation lines. /// Stores the start offset of continuation lines.
continuation_lines: Vec<TextSize>, continuation_lines: Vec<TextSize>,
/// The range of all f-string in the source document. The ranges are sorted by their /// The range of all f-string in the source document.
/// [`TextRange::start`] position in increasing order. No two ranges are overlapping. fstring_ranges: FStringRanges,
f_string_ranges: Vec<TextRange>,
} }
impl Indexer { impl Indexer {
@ -27,8 +28,8 @@ impl Indexer {
assert!(TextSize::try_from(locator.contents().len()).is_ok()); assert!(TextSize::try_from(locator.contents().len()).is_ok());
let mut comment_ranges_builder = CommentRangesBuilder::default(); let mut comment_ranges_builder = CommentRangesBuilder::default();
let mut fstring_ranges_builder = FStringRangesBuilder::default();
let mut continuation_lines = Vec::new(); let mut continuation_lines = Vec::new();
let mut f_string_ranges = Vec::new();
// Token, end // Token, end
let mut prev_end = TextSize::default(); let mut prev_end = TextSize::default();
let mut prev_token: Option<&Tok> = None; let mut prev_token: Option<&Tok> = None;
@ -59,18 +60,10 @@ impl Indexer {
} }
comment_ranges_builder.visit_token(tok, *range); comment_ranges_builder.visit_token(tok, *range);
fstring_ranges_builder.visit_token(tok, *range);
match tok { if matches!(tok, Tok::Newline | Tok::NonLogicalNewline) {
Tok::Newline | Tok::NonLogicalNewline => { line_start = range.end();
line_start = range.end();
}
Tok::String {
kind: StringKind::FString | StringKind::RawFString,
..
} => {
f_string_ranges.push(*range);
}
_ => {}
} }
prev_token = Some(tok); prev_token = Some(tok);
@ -79,7 +72,7 @@ impl Indexer {
Self { Self {
comment_ranges: comment_ranges_builder.finish(), comment_ranges: comment_ranges_builder.finish(),
continuation_lines, continuation_lines,
f_string_ranges, fstring_ranges: fstring_ranges_builder.finish(),
} }
} }
@ -88,6 +81,11 @@ impl Indexer {
&self.comment_ranges &self.comment_ranges
} }
/// Returns the byte offset ranges of f-strings.
pub const fn fstring_ranges(&self) -> &FStringRanges {
&self.fstring_ranges
}
/// Returns the line start positions of continuations (backslash). /// Returns the line start positions of continuations (backslash).
pub fn continuation_line_starts(&self) -> &[TextSize] { pub fn continuation_line_starts(&self) -> &[TextSize] {
&self.continuation_lines &self.continuation_lines
@ -99,22 +97,6 @@ impl Indexer {
self.continuation_lines.binary_search(&line_start).is_ok() self.continuation_lines.binary_search(&line_start).is_ok()
} }
/// Return the [`TextRange`] of the f-string containing a given offset.
pub fn f_string_range(&self, offset: TextSize) -> Option<TextRange> {
let Ok(string_range_index) = self.f_string_ranges.binary_search_by(|range| {
if offset < range.start() {
std::cmp::Ordering::Greater
} else if range.contains(offset) {
std::cmp::Ordering::Equal
} else {
std::cmp::Ordering::Less
}
}) else {
return None;
};
Some(self.f_string_ranges[string_range_index])
}
/// Returns `true` if a statement or expression includes at least one comment. /// Returns `true` if a statement or expression includes at least one comment.
pub fn has_comments<T>(&self, node: &T, locator: &Locator) -> bool pub fn has_comments<T>(&self, node: &T, locator: &Locator) -> bool
where where
@ -250,7 +232,7 @@ mod tests {
use ruff_python_parser::lexer::LexResult; use ruff_python_parser::lexer::LexResult;
use ruff_python_parser::{lexer, Mode}; use ruff_python_parser::{lexer, Mode};
use ruff_source_file::Locator; use ruff_source_file::Locator;
use ruff_text_size::TextSize; use ruff_text_size::{TextRange, TextSize};
use crate::Indexer; use crate::Indexer;
@ -333,5 +315,203 @@ import os
TextSize::from(116) TextSize::from(116)
] ]
); );
let contents = r"
f'foo { 'str1' \
'str2' \
'str3'
f'nested { 'str4'
'str5' \
'str6'
}'
}'
"
.trim();
let lxr: Vec<LexResult> = lexer::lex(contents, Mode::Module).collect();
let indexer = Indexer::from_tokens(lxr.as_slice(), &Locator::new(contents));
assert_eq!(
indexer.continuation_line_starts(),
[
// row 1
TextSize::new(0),
// row 2
TextSize::new(17),
// row 5
TextSize::new(63),
]
);
}
#[test]
fn test_f_string_ranges() {
let contents = r#"
f"normal f-string"
f"start {f"inner {f"another"}"} end"
f"implicit " f"concatenation"
"#
.trim();
let lxr: Vec<LexResult> = lexer::lex(contents, Mode::Module).collect();
let indexer = Indexer::from_tokens(lxr.as_slice(), &Locator::new(contents));
assert_eq!(
indexer
.fstring_ranges()
.values()
.copied()
.collect::<Vec<_>>(),
&[
TextRange::new(TextSize::from(0), TextSize::from(18)),
TextRange::new(TextSize::from(19), TextSize::from(55)),
TextRange::new(TextSize::from(28), TextSize::from(49)),
TextRange::new(TextSize::from(37), TextSize::from(47)),
TextRange::new(TextSize::from(56), TextSize::from(68)),
TextRange::new(TextSize::from(69), TextSize::from(85)),
]
);
}
#[test]
fn test_triple_quoted_f_string_ranges() {
let contents = r#"
f"""
this is one
multiline f-string
"""
f'''
and this is
another
'''
f"""
this is a {f"""nested multiline
f-string"""}
"""
"#
.trim();
let lxr: Vec<LexResult> = lexer::lex(contents, Mode::Module).collect();
let indexer = Indexer::from_tokens(lxr.as_slice(), &Locator::new(contents));
assert_eq!(
indexer
.fstring_ranges()
.values()
.copied()
.collect::<Vec<_>>(),
&[
TextRange::new(TextSize::from(0), TextSize::from(39)),
TextRange::new(TextSize::from(40), TextSize::from(68)),
TextRange::new(TextSize::from(69), TextSize::from(122)),
TextRange::new(TextSize::from(85), TextSize::from(117)),
]
);
}
#[test]
fn test_fstring_innermost_outermost() {
let contents = r#"
f"no nested f-string"
if True:
f"first {f"second {f"third"} second"} first"
foo = "normal string"
f"implicit " f"concatenation"
f"first line {
foo + f"second line {bar}"
} third line"
f"""this is a
multi-line {f"""nested
f-string"""}
the end"""
"#
.trim();
let lxr: Vec<LexResult> = lexer::lex(contents, Mode::Module).collect();
let indexer = Indexer::from_tokens(lxr.as_slice(), &Locator::new(contents));
// For reference, the ranges of the f-strings in the above code are as
// follows where the ones inside parentheses are nested f-strings:
//
// [0..21, (36..80, 45..72, 55..63), 108..120, 121..137, (139..198, 164..184), (200..260, 226..248)]
for (offset, innermost_range, outermost_range) in [
// Inside a normal f-string
(
TextSize::new(130),
TextRange::new(TextSize::new(121), TextSize::new(137)),
TextRange::new(TextSize::new(121), TextSize::new(137)),
),
// Left boundary
(
TextSize::new(121),
TextRange::new(TextSize::new(121), TextSize::new(137)),
TextRange::new(TextSize::new(121), TextSize::new(137)),
),
// Right boundary
(
TextSize::new(136), // End offsets are exclusive
TextRange::new(TextSize::new(121), TextSize::new(137)),
TextRange::new(TextSize::new(121), TextSize::new(137)),
),
// "first" left
(
TextSize::new(40),
TextRange::new(TextSize::new(36), TextSize::new(80)),
TextRange::new(TextSize::new(36), TextSize::new(80)),
),
// "second" left
(
TextSize::new(50),
TextRange::new(TextSize::new(45), TextSize::new(72)),
TextRange::new(TextSize::new(36), TextSize::new(80)),
),
// "third"
(
TextSize::new(60),
TextRange::new(TextSize::new(55), TextSize::new(63)),
TextRange::new(TextSize::new(36), TextSize::new(80)),
),
// "second" right
(
TextSize::new(70),
TextRange::new(TextSize::new(45), TextSize::new(72)),
TextRange::new(TextSize::new(36), TextSize::new(80)),
),
// "first" right
(
TextSize::new(75),
TextRange::new(TextSize::new(36), TextSize::new(80)),
TextRange::new(TextSize::new(36), TextSize::new(80)),
),
// Single-quoted f-strings spanning across multiple lines
(
TextSize::new(160),
TextRange::new(TextSize::new(139), TextSize::new(198)),
TextRange::new(TextSize::new(139), TextSize::new(198)),
),
(
TextSize::new(170),
TextRange::new(TextSize::new(164), TextSize::new(184)),
TextRange::new(TextSize::new(139), TextSize::new(198)),
),
// Multi-line f-strings
(
TextSize::new(220),
TextRange::new(TextSize::new(200), TextSize::new(260)),
TextRange::new(TextSize::new(200), TextSize::new(260)),
),
(
TextSize::new(240),
TextRange::new(TextSize::new(226), TextSize::new(248)),
TextRange::new(TextSize::new(200), TextSize::new(260)),
),
] {
assert_eq!(
indexer.fstring_ranges().innermost(offset).unwrap(),
innermost_range
);
assert_eq!(
indexer.fstring_ranges().outermost(offset).unwrap(),
outermost_range
);
}
} }
} }

View file

@ -1,4 +1,5 @@
mod comment_ranges; mod comment_ranges;
mod fstring_ranges;
mod indexer; mod indexer;
pub use comment_ranges::{tokens_and_ranges, CommentRangesBuilder}; pub use comment_ranges::{tokens_and_ranges, CommentRangesBuilder};

View file

@ -18,6 +18,7 @@ ruff_python_ast = { path = "../ruff_python_ast" }
ruff_text_size = { path = "../ruff_text_size" } ruff_text_size = { path = "../ruff_text_size" }
anyhow = { workspace = true } anyhow = { workspace = true }
bitflags = { workspace = true }
is-macro = { workspace = true } is-macro = { workspace = true }
itertools = { workspace = true } itertools = { workspace = true }
lalrpop-util = { version = "0.20.0", default-features = false } lalrpop-util = { version = "0.20.0", default-features = false }

View file

@ -37,6 +37,7 @@ use ruff_python_ast::{Int, IpyEscapeKind};
use ruff_text_size::{TextLen, TextRange, TextSize}; use ruff_text_size::{TextLen, TextRange, TextSize};
use crate::lexer::cursor::{Cursor, EOF_CHAR}; use crate::lexer::cursor::{Cursor, EOF_CHAR};
use crate::lexer::fstring::{FStringContext, FStringContextFlags, FStrings};
use crate::lexer::indentation::{Indentation, Indentations}; use crate::lexer::indentation::{Indentation, Indentations};
use crate::{ use crate::{
soft_keywords::SoftKeywordTransformer, soft_keywords::SoftKeywordTransformer,
@ -46,6 +47,7 @@ use crate::{
}; };
mod cursor; mod cursor;
mod fstring;
mod indentation; mod indentation;
/// A lexer for Python source code. /// A lexer for Python source code.
@ -62,6 +64,8 @@ pub struct Lexer<'source> {
pending_indentation: Option<Indentation>, pending_indentation: Option<Indentation>,
// Lexer mode. // Lexer mode.
mode: Mode, mode: Mode,
// F-string contexts.
fstrings: FStrings,
} }
/// Contains a Token along with its `range`. /// Contains a Token along with its `range`.
@ -154,6 +158,7 @@ impl<'source> Lexer<'source> {
source: input, source: input,
cursor: Cursor::new(input), cursor: Cursor::new(input),
mode, mode,
fstrings: FStrings::default(),
}; };
// TODO: Handle possible mismatch between BOM and explicit encoding declaration. // TODO: Handle possible mismatch between BOM and explicit encoding declaration.
// spell-checker:ignore feff // spell-checker:ignore feff
@ -165,16 +170,24 @@ impl<'source> Lexer<'source> {
/// Lex an identifier. Also used for keywords and string/bytes literals with a prefix. /// Lex an identifier. Also used for keywords and string/bytes literals with a prefix.
fn lex_identifier(&mut self, first: char) -> Result<Tok, LexicalError> { fn lex_identifier(&mut self, first: char) -> Result<Tok, LexicalError> {
// Detect potential string like rb'' b'' f'' u'' r'' // Detect potential string like rb'' b'' f'' u'' r''
match self.cursor.first() { match (first, self.cursor.first()) {
quote @ ('\'' | '"') => { ('f' | 'F', quote @ ('\'' | '"')) => {
self.cursor.bump();
return Ok(self.lex_fstring_start(quote, false));
}
('r' | 'R', 'f' | 'F') | ('f' | 'F', 'r' | 'R') if is_quote(self.cursor.second()) => {
self.cursor.bump();
let quote = self.cursor.bump().unwrap();
return Ok(self.lex_fstring_start(quote, true));
}
(_, quote @ ('\'' | '"')) => {
if let Ok(string_kind) = StringKind::try_from(first) { if let Ok(string_kind) = StringKind::try_from(first) {
self.cursor.bump(); self.cursor.bump();
return self.lex_string(string_kind, quote); return self.lex_string(string_kind, quote);
} }
} }
second @ ('f' | 'F' | 'r' | 'R' | 'b' | 'B') if is_quote(self.cursor.second()) => { (_, second @ ('r' | 'R' | 'b' | 'B')) if is_quote(self.cursor.second()) => {
self.cursor.bump(); self.cursor.bump();
if let Ok(string_kind) = StringKind::try_from([first, second]) { if let Ok(string_kind) = StringKind::try_from([first, second]) {
let quote = self.cursor.bump().unwrap(); let quote = self.cursor.bump().unwrap();
return self.lex_string(string_kind, quote); return self.lex_string(string_kind, quote);
@ -509,6 +522,148 @@ impl<'source> Lexer<'source> {
} }
} }
/// Lex a f-string start token.
fn lex_fstring_start(&mut self, quote: char, is_raw_string: bool) -> Tok {
#[cfg(debug_assertions)]
debug_assert_eq!(self.cursor.previous(), quote);
let mut flags = FStringContextFlags::empty();
if quote == '"' {
flags |= FStringContextFlags::DOUBLE;
}
if is_raw_string {
flags |= FStringContextFlags::RAW;
}
if self.cursor.eat_char2(quote, quote) {
flags |= FStringContextFlags::TRIPLE;
}
self.fstrings.push(FStringContext::new(flags, self.nesting));
Tok::FStringStart
}
/// Lex a f-string middle or end token.
fn lex_fstring_middle_or_end(&mut self) -> Result<Option<Tok>, LexicalError> {
// SAFETY: Safe because the function is only called when `self.fstrings` is not empty.
let fstring = self.fstrings.current().unwrap();
self.cursor.start_token();
// Check if we're at the end of the f-string.
if fstring.is_triple_quoted() {
let quote_char = fstring.quote_char();
if self.cursor.eat_char3(quote_char, quote_char, quote_char) {
return Ok(Some(Tok::FStringEnd));
}
} else if self.cursor.eat_char(fstring.quote_char()) {
return Ok(Some(Tok::FStringEnd));
}
// We have to decode `{{` and `}}` into `{` and `}` respectively. As an
// optimization, we only allocate a new string we find any escaped curly braces,
// otherwise this string will remain empty and we'll use a source slice instead.
let mut normalized = String::new();
// Tracks the last offset of token value that has been written to `normalized`.
let mut last_offset = self.offset();
let mut in_named_unicode = false;
loop {
match self.cursor.first() {
// The condition is to differentiate between the `NUL` (`\0`) character
// in the source code and the one returned by `self.cursor.first()` when
// we reach the end of the source code.
EOF_CHAR if self.cursor.is_eof() => {
let error = if fstring.is_triple_quoted() {
FStringErrorType::UnterminatedTripleQuotedString
} else {
FStringErrorType::UnterminatedString
};
return Err(LexicalError {
error: LexicalErrorType::FStringError(error),
location: self.offset(),
});
}
'\n' if !fstring.is_triple_quoted() => {
return Err(LexicalError {
error: LexicalErrorType::FStringError(FStringErrorType::UnterminatedString),
location: self.offset(),
});
}
'\\' => {
self.cursor.bump(); // '\'
if matches!(self.cursor.first(), '{' | '}') {
// Don't consume `{` or `}` as we want them to be emitted as tokens.
// They will be handled in the next iteration.
continue;
} else if !fstring.is_raw_string() {
if self.cursor.eat_char2('N', '{') {
in_named_unicode = true;
continue;
}
}
// Consume the escaped character.
self.cursor.bump();
}
quote @ ('\'' | '"') if quote == fstring.quote_char() => {
if let Some(triple_quotes) = fstring.triple_quotes() {
if self.cursor.rest().starts_with(triple_quotes) {
break;
}
self.cursor.bump();
} else {
break;
}
}
'{' => {
if self.cursor.second() == '{' {
self.cursor.bump();
normalized
.push_str(&self.source[TextRange::new(last_offset, self.offset())]);
self.cursor.bump(); // Skip the second `{`
last_offset = self.offset();
} else {
break;
}
}
'}' => {
if in_named_unicode {
in_named_unicode = false;
self.cursor.bump();
} else if self.cursor.second() == '}'
&& !fstring.is_in_format_spec(self.nesting)
{
self.cursor.bump();
normalized
.push_str(&self.source[TextRange::new(last_offset, self.offset())]);
self.cursor.bump(); // Skip the second `}`
last_offset = self.offset();
} else {
break;
}
}
_ => {
self.cursor.bump();
}
}
}
let range = self.token_range();
if range.is_empty() {
return Ok(None);
}
let value = if normalized.is_empty() {
self.source[range].to_string()
} else {
normalized.push_str(&self.source[TextRange::new(last_offset, self.offset())]);
normalized
};
Ok(Some(Tok::FStringMiddle {
value,
is_raw: fstring.is_raw_string(),
}))
}
/// Lex a string literal. /// Lex a string literal.
fn lex_string(&mut self, kind: StringKind, quote: char) -> Result<Tok, LexicalError> { fn lex_string(&mut self, kind: StringKind, quote: char) -> Result<Tok, LexicalError> {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -530,6 +685,19 @@ impl<'source> Lexer<'source> {
} }
} }
Some('\r' | '\n') if !triple_quoted => { Some('\r' | '\n') if !triple_quoted => {
if let Some(fstring) = self.fstrings.current() {
// When we are in an f-string, check whether does the initial quote
// matches with f-strings quotes and if it is, then this must be a
// missing '}' token so raise the proper error.
if fstring.quote_char() == quote && !fstring.is_triple_quoted() {
return Err(LexicalError {
error: LexicalErrorType::FStringError(
FStringErrorType::UnclosedLbrace,
),
location: self.offset() - fstring.quote_size(),
});
}
}
return Err(LexicalError { return Err(LexicalError {
error: LexicalErrorType::OtherError( error: LexicalErrorType::OtherError(
"EOL while scanning string literal".to_owned(), "EOL while scanning string literal".to_owned(),
@ -549,6 +717,21 @@ impl<'source> Lexer<'source> {
Some(_) => {} Some(_) => {}
None => { None => {
if let Some(fstring) = self.fstrings.current() {
// When we are in an f-string, check whether does the initial quote
// matches with f-strings quotes and if it is, then this must be a
// missing '}' token so raise the proper error.
if fstring.quote_char() == quote
&& fstring.is_triple_quoted() == triple_quoted
{
return Err(LexicalError {
error: LexicalErrorType::FStringError(
FStringErrorType::UnclosedLbrace,
),
location: self.offset() - fstring.quote_size(),
});
}
}
return Err(LexicalError { return Err(LexicalError {
error: if triple_quoted { error: if triple_quoted {
LexicalErrorType::Eof LexicalErrorType::Eof
@ -572,8 +755,28 @@ impl<'source> Lexer<'source> {
// This is the main entry point. Call this function to retrieve the next token. // This is the main entry point. Call this function to retrieve the next token.
// This function is used by the iterator implementation. // This function is used by the iterator implementation.
pub fn next_token(&mut self) -> LexResult { pub fn next_token(&mut self) -> LexResult {
if let Some(fstring) = self.fstrings.current() {
if !fstring.is_in_expression(self.nesting) {
match self.lex_fstring_middle_or_end() {
Ok(Some(tok)) => {
if tok == Tok::FStringEnd {
self.fstrings.pop();
}
return Ok((tok, self.token_range()));
}
Err(e) => {
// This is to prevent an infinite loop in which the lexer
// continuously returns an error token because the f-string
// remains on the stack.
self.fstrings.pop();
return Err(e);
}
_ => {}
}
}
}
// Return dedent tokens until the current indentation level matches the indentation of the next token. // Return dedent tokens until the current indentation level matches the indentation of the next token.
if let Some(indentation) = self.pending_indentation.take() { else if let Some(indentation) = self.pending_indentation.take() {
match self.indentations.current().try_compare(indentation) { match self.indentations.current().try_compare(indentation) {
Ok(Ordering::Greater) => { Ok(Ordering::Greater) => {
self.pending_indentation = Some(indentation); self.pending_indentation = Some(indentation);
@ -894,10 +1097,7 @@ impl<'source> Lexer<'source> {
if self.cursor.eat_char('=') { if self.cursor.eat_char('=') {
Tok::NotEqual Tok::NotEqual
} else { } else {
return Err(LexicalError { Tok::Exclamation
error: LexicalErrorType::UnrecognizedToken { tok: '!' },
location: self.token_start(),
});
} }
} }
'~' => Tok::Tilde, '~' => Tok::Tilde,
@ -922,11 +1122,26 @@ impl<'source> Lexer<'source> {
Tok::Lbrace Tok::Lbrace
} }
'}' => { '}' => {
if let Some(fstring) = self.fstrings.current_mut() {
if fstring.nesting() == self.nesting {
return Err(LexicalError {
error: LexicalErrorType::FStringError(FStringErrorType::SingleRbrace),
location: self.token_start(),
});
}
fstring.try_end_format_spec(self.nesting);
}
self.nesting = self.nesting.saturating_sub(1); self.nesting = self.nesting.saturating_sub(1);
Tok::Rbrace Tok::Rbrace
} }
':' => { ':' => {
if self.cursor.eat_char('=') { if self
.fstrings
.current_mut()
.is_some_and(|fstring| fstring.try_start_format_spec(self.nesting))
{
Tok::Colon
} else if self.cursor.eat_char('=') {
Tok::ColonEqual Tok::ColonEqual
} else { } else {
Tok::Colon Tok::Colon
@ -1743,4 +1958,191 @@ def f(arg=%timeit a = b):
.collect(); .collect();
assert_debug_snapshot!(tokens); assert_debug_snapshot!(tokens);
} }
#[test]
fn test_empty_fstrings() {
let source = r#"f"" "" F"" f'' '' f"""""" f''''''"#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_prefix() {
let source = r#"f"" F"" rf"" rF"" Rf"" RF"" fr"" Fr"" fR"" FR"""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring() {
let source = r#"f"normal {foo} {{another}} {bar} {{{three}}}""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_parentheses() {
let source = r#"f"{}" f"{{}}" f" {}" f"{{{}}}" f"{{{{}}}}" f" {} {{}} {{{}}} {{{{}}}} ""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_escape() {
let source = r#"f"\{x:\"\{x}} \"\"\
end""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_escape_braces() {
let source = r"f'\{foo}' f'\\{foo}' f'\{{foo}}' f'\\{{foo}}'";
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_escape_raw() {
let source = r#"rf"\{x:\"\{x}} \"\"\
end""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_named_unicode() {
let source = r#"f"\N{BULLET} normal \Nope \N""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_named_unicode_raw() {
let source = r#"rf"\N{BULLET} normal""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_with_named_expression() {
let source = r#"f"{x:=10} {(x:=10)} {x,{y:=10}} {[x:=10]}""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_with_format_spec() {
let source = r#"f"{foo:} {x=!s:.3f} {x:.{y}f} {'':*^{1:{1}}}""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_conversion() {
let source = r#"f"{x!s} {x=!r} {x:.3f!r} {{x!r}}""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_nested() {
let source = r#"f"foo {f"bar {x + f"{wow}"}"} baz" f'foo {f'bar'} some {f"another"}'"#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_expression_multiline() {
let source = r#"f"first {
x
*
y
} second""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_multiline() {
let source = r#"f"""
hello
world
""" f'''
world
hello
''' f"some {f"""multiline
allowed {x}"""} string""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_comments() {
let source = r#"f"""
# not a comment { # comment {
x
} # not a comment
""""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_with_ipy_escape_command() {
let source = r#"f"foo {!pwd} bar""#;
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_with_lambda_expression() {
let source = r#"
f"{lambda x:{x}}"
f"{(lambda x:{x})}"
"#
.trim();
assert_debug_snapshot!(lex_source(source));
}
#[test]
fn test_fstring_with_nul_char() {
let source = r"f'\0'";
assert_debug_snapshot!(lex_source(source));
}
fn lex_fstring_error(source: &str) -> FStringErrorType {
match lex(source, Mode::Module).find_map(std::result::Result::err) {
Some(err) => match err.error {
LexicalErrorType::FStringError(error) => error,
_ => panic!("Expected FStringError: {err:?}"),
},
_ => panic!("Expected atleast one FStringError"),
}
}
#[test]
fn test_fstring_error() {
use FStringErrorType::{
SingleRbrace, UnclosedLbrace, UnterminatedString, UnterminatedTripleQuotedString,
};
assert_eq!(lex_fstring_error("f'}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'{{}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'{{}}}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'foo}'"), SingleRbrace);
assert_eq!(lex_fstring_error(r"f'\u007b}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'{a:b}}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'{3:}}>10}'"), SingleRbrace);
assert_eq!(lex_fstring_error(r"f'\{foo}\}'"), SingleRbrace);
assert_eq!(lex_fstring_error("f'{'"), UnclosedLbrace);
assert_eq!(lex_fstring_error("f'{foo!r'"), UnclosedLbrace);
assert_eq!(lex_fstring_error("f'{foo='"), UnclosedLbrace);
assert_eq!(
lex_fstring_error(
r#"f"{"
"#
),
UnclosedLbrace
);
assert_eq!(lex_fstring_error(r#"f"""{""""#), UnclosedLbrace);
assert_eq!(lex_fstring_error(r#"f""#), UnterminatedString);
assert_eq!(lex_fstring_error(r#"f'"#), UnterminatedString);
assert_eq!(lex_fstring_error(r#"f""""#), UnterminatedTripleQuotedString);
assert_eq!(lex_fstring_error(r#"f'''"#), UnterminatedTripleQuotedString);
assert_eq!(
lex_fstring_error(r#"f"""""#),
UnterminatedTripleQuotedString
);
assert_eq!(
lex_fstring_error(r#"f""""""#),
UnterminatedTripleQuotedString
);
}
} }

View file

@ -96,6 +96,18 @@ impl<'a> Cursor<'a> {
} }
} }
pub(super) fn eat_char3(&mut self, c1: char, c2: char, c3: char) -> bool {
let mut chars = self.chars.clone();
if chars.next() == Some(c1) && chars.next() == Some(c2) && chars.next() == Some(c3) {
self.bump();
self.bump();
self.bump();
true
} else {
false
}
}
pub(super) fn eat_if<F>(&mut self, mut predicate: F) -> Option<char> pub(super) fn eat_if<F>(&mut self, mut predicate: F) -> Option<char>
where where
F: FnMut(char) -> bool, F: FnMut(char) -> bool,

View file

@ -0,0 +1,161 @@
use bitflags::bitflags;
use ruff_text_size::TextSize;
bitflags! {
#[derive(Debug)]
pub(crate) struct FStringContextFlags: u8 {
/// The current f-string is a triple-quoted f-string i.e., the number of
/// opening quotes is 3. If this flag is not set, the number of opening
/// quotes is 1.
const TRIPLE = 1 << 0;
/// The current f-string is a double-quoted f-string. If this flag is not
/// set, the current f-string is a single-quoted f-string.
const DOUBLE = 1 << 1;
/// The current f-string is a raw f-string i.e., prefixed with `r`/`R`.
/// If this flag is not set, the current f-string is a normal f-string.
const RAW = 1 << 2;
}
}
/// The context representing the current f-string that the lexer is in.
#[derive(Debug)]
pub(crate) struct FStringContext {
flags: FStringContextFlags,
/// The level of nesting for the lexer when it entered the current f-string.
/// The nesting level includes all kinds of parentheses i.e., round, square,
/// and curly.
nesting: u32,
/// The current depth of format spec for the current f-string. This is because
/// there can be multiple format specs nested for the same f-string.
/// For example, `{a:{b:{c}}}` has 3 format specs.
format_spec_depth: u32,
}
impl FStringContext {
pub(crate) const fn new(flags: FStringContextFlags, nesting: u32) -> Self {
Self {
flags,
nesting,
format_spec_depth: 0,
}
}
pub(crate) const fn nesting(&self) -> u32 {
self.nesting
}
/// Returns the quote character for the current f-string.
pub(crate) const fn quote_char(&self) -> char {
if self.flags.contains(FStringContextFlags::DOUBLE) {
'"'
} else {
'\''
}
}
/// Returns the number of quotes for the current f-string.
pub(crate) const fn quote_size(&self) -> TextSize {
if self.is_triple_quoted() {
TextSize::new(3)
} else {
TextSize::new(1)
}
}
/// Returns the triple quotes for the current f-string if it is a triple-quoted
/// f-string, `None` otherwise.
pub(crate) const fn triple_quotes(&self) -> Option<&'static str> {
if self.is_triple_quoted() {
if self.flags.contains(FStringContextFlags::DOUBLE) {
Some(r#"""""#)
} else {
Some("'''")
}
} else {
None
}
}
/// Returns `true` if the current f-string is a raw f-string.
pub(crate) const fn is_raw_string(&self) -> bool {
self.flags.contains(FStringContextFlags::RAW)
}
/// Returns `true` if the current f-string is a triple-quoted f-string.
pub(crate) const fn is_triple_quoted(&self) -> bool {
self.flags.contains(FStringContextFlags::TRIPLE)
}
/// Calculates the number of open parentheses for the current f-string
/// based on the current level of nesting for the lexer.
const fn open_parentheses_count(&self, current_nesting: u32) -> u32 {
current_nesting.saturating_sub(self.nesting)
}
/// Returns `true` if the lexer is in a f-string expression i.e., between
/// two curly braces.
pub(crate) const fn is_in_expression(&self, current_nesting: u32) -> bool {
self.open_parentheses_count(current_nesting) > self.format_spec_depth
}
/// Returns `true` if the lexer is in a f-string format spec i.e., after a colon.
pub(crate) const fn is_in_format_spec(&self, current_nesting: u32) -> bool {
self.format_spec_depth > 0 && !self.is_in_expression(current_nesting)
}
/// Returns `true` if the context is in a valid position to start format spec
/// i.e., at the same level of nesting as the opening parentheses token.
/// Increments the format spec depth if it is.
///
/// This assumes that the current character for the lexer is a colon (`:`).
pub(crate) fn try_start_format_spec(&mut self, current_nesting: u32) -> bool {
if self
.open_parentheses_count(current_nesting)
.saturating_sub(self.format_spec_depth)
== 1
{
self.format_spec_depth += 1;
true
} else {
false
}
}
/// Decrements the format spec depth if the current f-string is in a format
/// spec.
pub(crate) fn try_end_format_spec(&mut self, current_nesting: u32) {
if self.is_in_format_spec(current_nesting) {
self.format_spec_depth = self.format_spec_depth.saturating_sub(1);
}
}
}
/// The f-strings stack is used to keep track of all the f-strings that the
/// lexer encounters. This is necessary because f-strings can be nested.
#[derive(Debug, Default)]
pub(crate) struct FStrings {
stack: Vec<FStringContext>,
}
impl FStrings {
pub(crate) fn push(&mut self, context: FStringContext) {
self.stack.push(context);
}
pub(crate) fn pop(&mut self) -> Option<FStringContext> {
self.stack.pop()
}
pub(crate) fn current(&self) -> Option<&FStringContext> {
self.stack.last()
}
pub(crate) fn current_mut(&mut self) -> Option<&mut FStringContext> {
self.stack.last_mut()
}
}

View file

@ -85,7 +85,7 @@
//! return bool(i & 1) //! return bool(i & 1)
//! "#; //! "#;
//! let tokens = lex(python_source, Mode::Module); //! let tokens = lex(python_source, Mode::Module);
//! let ast = parse_tokens(tokens, Mode::Module, "<embedded>"); //! let ast = parse_tokens(tokens, python_source, Mode::Module, "<embedded>");
//! //!
//! assert!(ast.is_ok()); //! assert!(ast.is_ok());
//! ``` //! ```
@ -146,6 +146,7 @@ pub fn tokenize(contents: &str, mode: Mode) -> Vec<LexResult> {
/// Parse a full Python program from its tokens. /// Parse a full Python program from its tokens.
pub fn parse_program_tokens( pub fn parse_program_tokens(
lxr: Vec<LexResult>, lxr: Vec<LexResult>,
source: &str,
source_path: &str, source_path: &str,
is_jupyter_notebook: bool, is_jupyter_notebook: bool,
) -> anyhow::Result<Suite, ParseError> { ) -> anyhow::Result<Suite, ParseError> {
@ -154,7 +155,7 @@ pub fn parse_program_tokens(
} else { } else {
Mode::Module Mode::Module
}; };
match parse_tokens(lxr, mode, source_path)? { match parse_tokens(lxr, source, mode, source_path)? {
Mod::Module(m) => Ok(m.body), Mod::Module(m) => Ok(m.body),
Mod::Expression(_) => unreachable!("Mode::Module doesn't return other variant"), Mod::Expression(_) => unreachable!("Mode::Module doesn't return other variant"),
} }

View file

@ -50,7 +50,7 @@ use ruff_python_ast::{Mod, ModModule, Suite};
/// ``` /// ```
pub fn parse_program(source: &str, source_path: &str) -> Result<ModModule, ParseError> { pub fn parse_program(source: &str, source_path: &str) -> Result<ModModule, ParseError> {
let lexer = lex(source, Mode::Module); let lexer = lex(source, Mode::Module);
match parse_tokens(lexer, Mode::Module, source_path)? { match parse_tokens(lexer, source, Mode::Module, source_path)? {
Mod::Module(m) => Ok(m), Mod::Module(m) => Ok(m),
Mod::Expression(_) => unreachable!("Mode::Module doesn't return other variant"), Mod::Expression(_) => unreachable!("Mode::Module doesn't return other variant"),
} }
@ -78,7 +78,7 @@ pub fn parse_suite(source: &str, source_path: &str) -> Result<Suite, ParseError>
/// ``` /// ```
pub fn parse_expression(source: &str, source_path: &str) -> Result<ast::Expr, ParseError> { pub fn parse_expression(source: &str, source_path: &str) -> Result<ast::Expr, ParseError> {
let lexer = lex(source, Mode::Expression); let lexer = lex(source, Mode::Expression);
match parse_tokens(lexer, Mode::Expression, source_path)? { match parse_tokens(lexer, source, Mode::Expression, source_path)? {
Mod::Expression(expression) => Ok(*expression.body), Mod::Expression(expression) => Ok(*expression.body),
Mod::Module(_m) => unreachable!("Mode::Expression doesn't return other variant"), Mod::Module(_m) => unreachable!("Mode::Expression doesn't return other variant"),
} }
@ -107,7 +107,7 @@ pub fn parse_expression_starts_at(
offset: TextSize, offset: TextSize,
) -> Result<ast::Expr, ParseError> { ) -> Result<ast::Expr, ParseError> {
let lexer = lex_starts_at(source, Mode::Module, offset); let lexer = lex_starts_at(source, Mode::Module, offset);
match parse_tokens(lexer, Mode::Expression, source_path)? { match parse_tokens(lexer, source, Mode::Expression, source_path)? {
Mod::Expression(expression) => Ok(*expression.body), Mod::Expression(expression) => Ok(*expression.body),
Mod::Module(_m) => unreachable!("Mode::Expression doesn't return other variant"), Mod::Module(_m) => unreachable!("Mode::Expression doesn't return other variant"),
} }
@ -193,7 +193,7 @@ pub fn parse_starts_at(
offset: TextSize, offset: TextSize,
) -> Result<Mod, ParseError> { ) -> Result<Mod, ParseError> {
let lxr = lexer::lex_starts_at(source, mode, offset); let lxr = lexer::lex_starts_at(source, mode, offset);
parse_tokens(lxr, mode, source_path) parse_tokens(lxr, source, mode, source_path)
} }
/// Parse an iterator of [`LexResult`]s using the specified [`Mode`]. /// Parse an iterator of [`LexResult`]s using the specified [`Mode`].
@ -208,11 +208,13 @@ pub fn parse_starts_at(
/// ``` /// ```
/// use ruff_python_parser::{lexer::lex, Mode, parse_tokens}; /// use ruff_python_parser::{lexer::lex, Mode, parse_tokens};
/// ///
/// let expr = parse_tokens(lex("1 + 2", Mode::Expression), Mode::Expression, "<embedded>"); /// let source = "1 + 2";
/// let expr = parse_tokens(lex(source, Mode::Expression), source, Mode::Expression, "<embedded>");
/// assert!(expr.is_ok()); /// assert!(expr.is_ok());
/// ``` /// ```
pub fn parse_tokens( pub fn parse_tokens(
lxr: impl IntoIterator<Item = LexResult>, lxr: impl IntoIterator<Item = LexResult>,
source: &str,
mode: Mode, mode: Mode,
source_path: &str, source_path: &str,
) -> Result<Mod, ParseError> { ) -> Result<Mod, ParseError> {
@ -220,6 +222,7 @@ pub fn parse_tokens(
parse_filtered_tokens( parse_filtered_tokens(
lxr.filter_ok(|(tok, _)| !matches!(tok, Tok::Comment { .. } | Tok::NonLogicalNewline)), lxr.filter_ok(|(tok, _)| !matches!(tok, Tok::Comment { .. } | Tok::NonLogicalNewline)),
source,
mode, mode,
source_path, source_path,
) )
@ -228,6 +231,7 @@ pub fn parse_tokens(
/// Parse tokens into an AST like [`parse_tokens`], but we already know all tokens are valid. /// Parse tokens into an AST like [`parse_tokens`], but we already know all tokens are valid.
pub fn parse_ok_tokens( pub fn parse_ok_tokens(
lxr: impl IntoIterator<Item = Spanned>, lxr: impl IntoIterator<Item = Spanned>,
source: &str,
mode: Mode, mode: Mode,
source_path: &str, source_path: &str,
) -> Result<Mod, ParseError> { ) -> Result<Mod, ParseError> {
@ -239,12 +243,13 @@ pub fn parse_ok_tokens(
.chain(lxr) .chain(lxr)
.map(|(t, range)| (range.start(), t, range.end())); .map(|(t, range)| (range.start(), t, range.end()));
python::TopParser::new() python::TopParser::new()
.parse(mode, lexer) .parse(source, mode, lexer)
.map_err(|e| parse_error_from_lalrpop(e, source_path)) .map_err(|e| parse_error_from_lalrpop(e, source_path))
} }
fn parse_filtered_tokens( fn parse_filtered_tokens(
lxr: impl IntoIterator<Item = LexResult>, lxr: impl IntoIterator<Item = LexResult>,
source: &str,
mode: Mode, mode: Mode,
source_path: &str, source_path: &str,
) -> Result<Mod, ParseError> { ) -> Result<Mod, ParseError> {
@ -252,6 +257,7 @@ fn parse_filtered_tokens(
let lexer = iter::once(Ok(marker_token)).chain(lxr); let lexer = iter::once(Ok(marker_token)).chain(lxr);
python::TopParser::new() python::TopParser::new()
.parse( .parse(
source,
mode, mode,
lexer.map_ok(|(t, range)| (range.start(), t, range.end())), lexer.map_ok(|(t, range)| (range.start(), t, range.end())),
) )
@ -1253,11 +1259,58 @@ a = 1
"# "#
.trim(); .trim();
let lxr = lexer::lex_starts_at(source, Mode::Ipython, TextSize::default()); let lxr = lexer::lex_starts_at(source, Mode::Ipython, TextSize::default());
let parse_err = parse_tokens(lxr, Mode::Module, "<test>").unwrap_err(); let parse_err = parse_tokens(lxr, source, Mode::Module, "<test>").unwrap_err();
assert_eq!( assert_eq!(
parse_err.to_string(), parse_err.to_string(),
"IPython escape commands are only allowed in `Mode::Ipython` at byte offset 6" "IPython escape commands are only allowed in `Mode::Ipython` at byte offset 6"
.to_string() .to_string()
); );
} }
#[test]
fn test_fstrings() {
let parse_ast = parse_suite(
r#"
f"{" f"}"
f"{foo!s}"
f"{3,}"
f"{3!=4:}"
f'{3:{"}"}>10}'
f'{3:{"{"}>10}'
f"{ foo = }"
f"{ foo = :.3f }"
f"{ foo = !s }"
f"{ 1, 2 = }"
f'{f"{3.1415=:.1f}":*^20}'
{"foo " f"bar {x + y} " "baz": 10}
match foo:
case "foo " f"bar {x + y} " "baz":
pass
f"\{foo}\{bar:\}"
f"\\{{foo\\}}"
"#
.trim(),
"<test>",
)
.unwrap();
insta::assert_debug_snapshot!(parse_ast);
}
#[test]
fn test_fstrings_with_unicode() {
let parse_ast = parse_suite(
r#"
u"foo" f"{bar}" "baz" " some"
"foo" f"{bar}" u"baz" " some"
"foo" f"{bar}" "baz" u" some"
u"foo" f"bar {baz} really" u"bar" "no"
"#
.trim(),
"<test>",
)
.unwrap();
insta::assert_debug_snapshot!(parse_ast);
}
} }

View file

@ -3,19 +3,20 @@
// See also: file:///usr/share/doc/python/html/reference/compound_stmts.html#function-definitions // See also: file:///usr/share/doc/python/html/reference/compound_stmts.html#function-definitions
// See also: https://greentreesnakes.readthedocs.io/en/latest/nodes.html#keyword // See also: https://greentreesnakes.readthedocs.io/en/latest/nodes.html#keyword
use ruff_text_size::{Ranged, TextSize}; use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use ruff_python_ast::{self as ast, Int, IpyEscapeKind}; use ruff_python_ast::{self as ast, Int, IpyEscapeKind};
use crate::{ use crate::{
FStringErrorType,
Mode, Mode,
lexer::{LexicalError, LexicalErrorType}, lexer::{LexicalError, LexicalErrorType},
function::{ArgumentList, parse_arguments, validate_pos_params, validate_arguments}, function::{ArgumentList, parse_arguments, validate_pos_params, validate_arguments},
context::set_context, context::set_context,
string::parse_strings, string::{StringType, concatenate_strings, parse_fstring_middle, parse_string_literal},
token::{self, StringKind}, token::{self, StringKind},
}; };
use lalrpop_util::ParseError; use lalrpop_util::ParseError;
grammar(mode: Mode); grammar(source_code: &str, mode: Mode);
// This is a hack to reduce the amount of lalrpop tables generated: // This is a hack to reduce the amount of lalrpop tables generated:
// For each public entry point, a full parse table is generated. // For each public entry point, a full parse table is generated.
@ -667,8 +668,8 @@ LiteralPattern: ast::Pattern = {
value: Box::new(value.into()), value: Box::new(value.into()),
range: (location..end_location).into() range: (location..end_location).into()
}.into(), }.into(),
<location:@L> <s:(@L string @R)+> <end_location:@R> =>? Ok(ast::PatternMatchValue { <location:@L> <strings:StringLiteralOrFString+> <end_location:@R> =>? Ok(ast::PatternMatchValue {
value: Box::new(parse_strings(s)?), value: Box::new(concatenate_strings(strings, (location..end_location).into())?),
range: (location..end_location).into() range: (location..end_location).into()
}.into()), }.into()),
} }
@ -725,7 +726,7 @@ MappingKey: ast::Expr = {
value: false.into(), value: false.into(),
range: (location..end_location).into() range: (location..end_location).into()
}.into(), }.into(),
<location:@L> <s:(@L string @R)+> =>? Ok(parse_strings(s)?), <location:@L> <strings:StringLiteralOrFString+> <end_location:@R> =>? Ok(concatenate_strings(strings, (location..end_location).into())?),
} }
MatchMappingEntry: (ast::Expr, ast::Pattern) = { MatchMappingEntry: (ast::Expr, ast::Pattern) = {
@ -1349,7 +1350,13 @@ NamedExpression: ast::ParenthesizedExpr = {
}; };
LambdaDef: ast::ParenthesizedExpr = { LambdaDef: ast::ParenthesizedExpr = {
<location:@L> "lambda" <location_args:@L> <parameters:ParameterList<UntypedParameter, StarUntypedParameter, StarUntypedParameter>?> <end_location_args:@R> ":" <body:Test<"all">> <end_location:@R> =>? { <location:@L> "lambda" <location_args:@L> <parameters:ParameterList<UntypedParameter, StarUntypedParameter, StarUntypedParameter>?> <end_location_args:@R> ":" <fstring_middle:fstring_middle?> <body:Test<"all">> <end_location:@R> =>? {
if fstring_middle.is_some() {
return Err(LexicalError {
error: LexicalErrorType::FStringError(FStringErrorType::LambdaWithoutParentheses),
location,
})?;
}
parameters.as_ref().map(validate_arguments).transpose()?; parameters.as_ref().map(validate_arguments).transpose()?;
Ok(ast::ExprLambda { Ok(ast::ExprLambda {
@ -1572,8 +1579,105 @@ SliceOp: Option<ast::ParenthesizedExpr> = {
<location:@L> ":" <e:Test<"all">?> => e, <location:@L> ":" <e:Test<"all">?> => e,
} }
StringLiteralOrFString: StringType = {
StringLiteral,
FStringExpr,
};
StringLiteral: StringType = {
<start_location:@L> <string:string> =>? {
let (source, kind, triple_quoted) = string;
Ok(parse_string_literal(&source, kind, triple_quoted, start_location)?)
}
};
FStringExpr: StringType = {
<location:@L> FStringStart <values:FStringMiddlePattern*> FStringEnd <end_location:@R> => {
StringType::FString(ast::ExprFString {
values,
implicit_concatenated: false,
range: (location..end_location).into()
})
}
};
FStringMiddlePattern: ast::Expr = {
FStringReplacementField,
<start_location:@L> <fstring_middle:fstring_middle> =>? {
let (source, is_raw) = fstring_middle;
Ok(parse_fstring_middle(&source, is_raw, start_location)?)
}
};
FStringReplacementField: ast::Expr = {
<location:@L> "{" <value:TestListOrYieldExpr> <debug:"="?> <conversion:FStringConversion?> <format_spec:FStringFormatSpecSuffix?> "}" <end_location:@R> =>? {
if value.expr.is_lambda_expr() && !value.is_parenthesized() {
return Err(LexicalError {
error: LexicalErrorType::FStringError(FStringErrorType::LambdaWithoutParentheses),
location: value.start(),
})?;
}
let debug_text = debug.map(|_| {
let start_offset = location + "{".text_len();
let end_offset = if let Some((conversion_start, _)) = conversion {
conversion_start
} else {
format_spec.as_ref().map_or_else(
|| end_location - "}".text_len(),
|spec| spec.range().start() - ":".text_len(),
)
};
ast::DebugText {
leading: source_code[TextRange::new(start_offset, value.range().start())].to_string(),
trailing: source_code[TextRange::new(value.range().end(), end_offset)].to_string(),
}
});
Ok(
ast::ExprFormattedValue {
value: Box::new(value.into()),
debug_text,
conversion: conversion.map_or(ast::ConversionFlag::None, |(_, conversion_flag)| {
conversion_flag
}),
format_spec: format_spec.map(Box::new),
range: (location..end_location).into(),
}
.into()
)
}
};
FStringFormatSpecSuffix: ast::Expr = {
":" <format_spec:FStringFormatSpec> => format_spec
};
FStringFormatSpec: ast::Expr = {
<location:@L> <values:FStringMiddlePattern*> <end_location:@R> => {
ast::ExprFString {
values,
implicit_concatenated: false,
range: (location..end_location).into()
}.into()
},
};
FStringConversion: (TextSize, ast::ConversionFlag) = {
<location:@L> "!" <s:name> =>? {
let conversion = match s.as_str() {
"s" => ast::ConversionFlag::Str,
"r" => ast::ConversionFlag::Repr,
"a" => ast::ConversionFlag::Ascii,
_ => Err(LexicalError {
error: LexicalErrorType::FStringError(FStringErrorType::InvalidConversionFlag),
location,
})?
};
Ok((location, conversion))
}
};
Atom<Goal>: ast::ParenthesizedExpr = { Atom<Goal>: ast::ParenthesizedExpr = {
<location:@L> <s:(@L string @R)+> =>? Ok(parse_strings(s)?.into()), <location:@L> <strings:StringLiteralOrFString+> <end_location:@R> =>? Ok(concatenate_strings(strings, (location..end_location).into())?.into()),
<location:@L> <value:Constant> <end_location:@R> => ast::ExprConstant { <location:@L> <value:Constant> <end_location:@R> => ast::ExprConstant {
value, value,
range: (location..end_location).into(), range: (location..end_location).into(),
@ -1842,6 +1946,9 @@ extern {
Dedent => token::Tok::Dedent, Dedent => token::Tok::Dedent,
StartModule => token::Tok::StartModule, StartModule => token::Tok::StartModule,
StartExpression => token::Tok::StartExpression, StartExpression => token::Tok::StartExpression,
FStringStart => token::Tok::FStringStart,
FStringEnd => token::Tok::FStringEnd,
"!" => token::Tok::Exclamation,
"?" => token::Tok::Question, "?" => token::Tok::Question,
"+" => token::Tok::Plus, "+" => token::Tok::Plus,
"-" => token::Tok::Minus, "-" => token::Tok::Minus,
@ -1935,6 +2042,10 @@ extern {
kind: <StringKind>, kind: <StringKind>,
triple_quoted: <bool> triple_quoted: <bool>
}, },
fstring_middle => token::Tok::FStringMiddle {
value: <String>,
is_raw: <bool>
},
name => token::Tok::Name { name: <String> }, name => token::Tok::Name { name: <String> },
ipy_escape_command => token::Tok::IpyEscapeCommand { ipy_escape_command => token::Tok::IpyEscapeCommand {
kind: <IpyEscapeKind>, kind: <IpyEscapeKind>,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringEnd,
2..3,
),
(
String {
value: "",
kind: String,
triple_quoted: false,
},
4..6,
),
(
FStringStart,
7..9,
),
(
FStringEnd,
9..10,
),
(
FStringStart,
11..13,
),
(
FStringEnd,
13..14,
),
(
String {
value: "",
kind: String,
triple_quoted: false,
},
15..17,
),
(
FStringStart,
18..22,
),
(
FStringEnd,
22..25,
),
(
FStringStart,
26..30,
),
(
FStringEnd,
30..33,
),
(
Newline,
33..33,
),
]

View file

@ -0,0 +1,88 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "normal ",
is_raw: false,
},
2..9,
),
(
Lbrace,
9..10,
),
(
Name {
name: "foo",
},
10..13,
),
(
Rbrace,
13..14,
),
(
FStringMiddle {
value: " {another} ",
is_raw: false,
},
14..27,
),
(
Lbrace,
27..28,
),
(
Name {
name: "bar",
},
28..31,
),
(
Rbrace,
31..32,
),
(
FStringMiddle {
value: " {",
is_raw: false,
},
32..35,
),
(
Lbrace,
35..36,
),
(
Name {
name: "three",
},
36..41,
),
(
Rbrace,
41..42,
),
(
FStringMiddle {
value: "}",
is_raw: false,
},
42..44,
),
(
FStringEnd,
44..45,
),
(
Newline,
45..45,
),
]

View file

@ -0,0 +1,60 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..4,
),
(
FStringMiddle {
value: "\n# not a comment ",
is_raw: false,
},
4..21,
),
(
Lbrace,
21..22,
),
(
Comment(
"# comment {",
),
23..34,
),
(
NonLogicalNewline,
34..35,
),
(
Name {
name: "x",
},
39..40,
),
(
NonLogicalNewline,
40..41,
),
(
Rbrace,
41..42,
),
(
FStringMiddle {
value: " # not a comment\n",
is_raw: false,
},
42..59,
),
(
FStringEnd,
59..62,
),
(
Newline,
62..62,
),
]

View file

@ -0,0 +1,116 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
Lbrace,
2..3,
),
(
Name {
name: "x",
},
3..4,
),
(
Exclamation,
4..5,
),
(
Name {
name: "s",
},
5..6,
),
(
Rbrace,
6..7,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
7..8,
),
(
Lbrace,
8..9,
),
(
Name {
name: "x",
},
9..10,
),
(
Equal,
10..11,
),
(
Exclamation,
11..12,
),
(
Name {
name: "r",
},
12..13,
),
(
Rbrace,
13..14,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
14..15,
),
(
Lbrace,
15..16,
),
(
Name {
name: "x",
},
16..17,
),
(
Colon,
17..18,
),
(
FStringMiddle {
value: ".3f!r",
is_raw: false,
},
18..23,
),
(
Rbrace,
23..24,
),
(
FStringMiddle {
value: " {x!r}",
is_raw: false,
},
24..32,
),
(
FStringEnd,
32..33,
),
(
Newline,
33..33,
),
]

View file

@ -0,0 +1,71 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "\\",
is_raw: false,
},
2..3,
),
(
Lbrace,
3..4,
),
(
Name {
name: "x",
},
4..5,
),
(
Colon,
5..6,
),
(
FStringMiddle {
value: "\\\"\\",
is_raw: false,
},
6..9,
),
(
Lbrace,
9..10,
),
(
Name {
name: "x",
},
10..11,
),
(
Rbrace,
11..12,
),
(
Rbrace,
12..13,
),
(
FStringMiddle {
value: " \\\"\\\"\\\n end",
is_raw: false,
},
13..24,
),
(
FStringEnd,
24..25,
),
(
Newline,
25..25,
),
]

View file

@ -0,0 +1,98 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "\\",
is_raw: false,
},
2..3,
),
(
Lbrace,
3..4,
),
(
Name {
name: "foo",
},
4..7,
),
(
Rbrace,
7..8,
),
(
FStringEnd,
8..9,
),
(
FStringStart,
10..12,
),
(
FStringMiddle {
value: "\\\\",
is_raw: false,
},
12..14,
),
(
Lbrace,
14..15,
),
(
Name {
name: "foo",
},
15..18,
),
(
Rbrace,
18..19,
),
(
FStringEnd,
19..20,
),
(
FStringStart,
21..23,
),
(
FStringMiddle {
value: "\\{foo}",
is_raw: false,
},
23..31,
),
(
FStringEnd,
31..32,
),
(
FStringStart,
33..35,
),
(
FStringMiddle {
value: "\\\\{foo}",
is_raw: false,
},
35..44,
),
(
FStringEnd,
44..45,
),
(
Newline,
45..45,
),
]

View file

@ -0,0 +1,71 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..3,
),
(
FStringMiddle {
value: "\\",
is_raw: true,
},
3..4,
),
(
Lbrace,
4..5,
),
(
Name {
name: "x",
},
5..6,
),
(
Colon,
6..7,
),
(
FStringMiddle {
value: "\\\"\\",
is_raw: true,
},
7..10,
),
(
Lbrace,
10..11,
),
(
Name {
name: "x",
},
11..12,
),
(
Rbrace,
12..13,
),
(
Rbrace,
13..14,
),
(
FStringMiddle {
value: " \\\"\\\"\\\n end",
is_raw: true,
},
14..25,
),
(
FStringEnd,
25..26,
),
(
Newline,
26..26,
),
]

View file

@ -0,0 +1,72 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "first ",
is_raw: false,
},
2..8,
),
(
Lbrace,
8..9,
),
(
NonLogicalNewline,
9..10,
),
(
Name {
name: "x",
},
14..15,
),
(
NonLogicalNewline,
15..16,
),
(
Star,
24..25,
),
(
NonLogicalNewline,
25..26,
),
(
Name {
name: "y",
},
38..39,
),
(
NonLogicalNewline,
39..40,
),
(
Rbrace,
40..41,
),
(
FStringMiddle {
value: " second",
is_raw: false,
},
41..48,
),
(
FStringEnd,
48..49,
),
(
Newline,
49..49,
),
]

View file

@ -0,0 +1,99 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..4,
),
(
FStringMiddle {
value: "\nhello\n world\n",
is_raw: false,
},
4..21,
),
(
FStringEnd,
21..24,
),
(
FStringStart,
25..29,
),
(
FStringMiddle {
value: "\n world\nhello\n",
is_raw: false,
},
29..46,
),
(
FStringEnd,
46..49,
),
(
FStringStart,
50..52,
),
(
FStringMiddle {
value: "some ",
is_raw: false,
},
52..57,
),
(
Lbrace,
57..58,
),
(
FStringStart,
58..62,
),
(
FStringMiddle {
value: "multiline\nallowed ",
is_raw: false,
},
62..80,
),
(
Lbrace,
80..81,
),
(
Name {
name: "x",
},
81..82,
),
(
Rbrace,
82..83,
),
(
FStringEnd,
83..86,
),
(
Rbrace,
86..87,
),
(
FStringMiddle {
value: " string",
is_raw: false,
},
87..94,
),
(
FStringEnd,
94..95,
),
(
Newline,
95..95,
),
]

View file

@ -0,0 +1,25 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "\\N{BULLET} normal \\Nope \\N",
is_raw: false,
},
2..28,
),
(
FStringEnd,
28..29,
),
(
Newline,
29..29,
),
]

View file

@ -0,0 +1,46 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..3,
),
(
FStringMiddle {
value: "\\N",
is_raw: true,
},
3..5,
),
(
Lbrace,
5..6,
),
(
Name {
name: "BULLET",
},
6..12,
),
(
Rbrace,
12..13,
),
(
FStringMiddle {
value: " normal",
is_raw: true,
},
13..20,
),
(
FStringEnd,
20..21,
),
(
Newline,
21..21,
),
]

View file

@ -0,0 +1,163 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "foo ",
is_raw: false,
},
2..6,
),
(
Lbrace,
6..7,
),
(
FStringStart,
7..9,
),
(
FStringMiddle {
value: "bar ",
is_raw: false,
},
9..13,
),
(
Lbrace,
13..14,
),
(
Name {
name: "x",
},
14..15,
),
(
Plus,
16..17,
),
(
FStringStart,
18..20,
),
(
Lbrace,
20..21,
),
(
Name {
name: "wow",
},
21..24,
),
(
Rbrace,
24..25,
),
(
FStringEnd,
25..26,
),
(
Rbrace,
26..27,
),
(
FStringEnd,
27..28,
),
(
Rbrace,
28..29,
),
(
FStringMiddle {
value: " baz",
is_raw: false,
},
29..33,
),
(
FStringEnd,
33..34,
),
(
FStringStart,
35..37,
),
(
FStringMiddle {
value: "foo ",
is_raw: false,
},
37..41,
),
(
Lbrace,
41..42,
),
(
FStringStart,
42..44,
),
(
FStringMiddle {
value: "bar",
is_raw: false,
},
44..47,
),
(
FStringEnd,
47..48,
),
(
Rbrace,
48..49,
),
(
FStringMiddle {
value: " some ",
is_raw: false,
},
49..55,
),
(
Lbrace,
55..56,
),
(
FStringStart,
56..58,
),
(
FStringMiddle {
value: "another",
is_raw: false,
},
58..65,
),
(
FStringEnd,
65..66,
),
(
Rbrace,
66..67,
),
(
FStringEnd,
67..68,
),
(
Newline,
68..68,
),
]

View file

@ -0,0 +1,154 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
Lbrace,
2..3,
),
(
Rbrace,
3..4,
),
(
FStringEnd,
4..5,
),
(
FStringStart,
6..8,
),
(
FStringMiddle {
value: "{}",
is_raw: false,
},
8..12,
),
(
FStringEnd,
12..13,
),
(
FStringStart,
14..16,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
16..17,
),
(
Lbrace,
17..18,
),
(
Rbrace,
18..19,
),
(
FStringEnd,
19..20,
),
(
FStringStart,
21..23,
),
(
FStringMiddle {
value: "{",
is_raw: false,
},
23..25,
),
(
Lbrace,
25..26,
),
(
Rbrace,
26..27,
),
(
FStringMiddle {
value: "}",
is_raw: false,
},
27..29,
),
(
FStringEnd,
29..30,
),
(
FStringStart,
31..33,
),
(
FStringMiddle {
value: "{{}}",
is_raw: false,
},
33..41,
),
(
FStringEnd,
41..42,
),
(
FStringStart,
43..45,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
45..46,
),
(
Lbrace,
46..47,
),
(
Rbrace,
47..48,
),
(
FStringMiddle {
value: " {} {",
is_raw: false,
},
48..56,
),
(
Lbrace,
56..57,
),
(
Rbrace,
57..58,
),
(
FStringMiddle {
value: "} {{}} ",
is_raw: false,
},
58..71,
),
(
FStringEnd,
71..72,
),
(
Newline,
72..72,
),
]

View file

@ -0,0 +1,90 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringEnd,
2..3,
),
(
FStringStart,
4..6,
),
(
FStringEnd,
6..7,
),
(
FStringStart,
8..11,
),
(
FStringEnd,
11..12,
),
(
FStringStart,
13..16,
),
(
FStringEnd,
16..17,
),
(
FStringStart,
18..21,
),
(
FStringEnd,
21..22,
),
(
FStringStart,
23..26,
),
(
FStringEnd,
26..27,
),
(
FStringStart,
28..31,
),
(
FStringEnd,
31..32,
),
(
FStringStart,
33..36,
),
(
FStringEnd,
36..37,
),
(
FStringStart,
38..41,
),
(
FStringEnd,
41..42,
),
(
FStringStart,
43..46,
),
(
FStringEnd,
46..47,
),
(
Newline,
47..47,
),
]

View file

@ -0,0 +1,201 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
Lbrace,
2..3,
),
(
Name {
name: "foo",
},
3..6,
),
(
Colon,
6..7,
),
(
Rbrace,
7..8,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
8..9,
),
(
Lbrace,
9..10,
),
(
Name {
name: "x",
},
10..11,
),
(
Equal,
11..12,
),
(
Exclamation,
12..13,
),
(
Name {
name: "s",
},
13..14,
),
(
Colon,
14..15,
),
(
FStringMiddle {
value: ".3f",
is_raw: false,
},
15..18,
),
(
Rbrace,
18..19,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
19..20,
),
(
Lbrace,
20..21,
),
(
Name {
name: "x",
},
21..22,
),
(
Colon,
22..23,
),
(
FStringMiddle {
value: ".",
is_raw: false,
},
23..24,
),
(
Lbrace,
24..25,
),
(
Name {
name: "y",
},
25..26,
),
(
Rbrace,
26..27,
),
(
FStringMiddle {
value: "f",
is_raw: false,
},
27..28,
),
(
Rbrace,
28..29,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
29..30,
),
(
Lbrace,
30..31,
),
(
String {
value: "",
kind: String,
triple_quoted: false,
},
31..33,
),
(
Colon,
33..34,
),
(
FStringMiddle {
value: "*^",
is_raw: false,
},
34..36,
),
(
Lbrace,
36..37,
),
(
Int {
value: 1,
},
37..38,
),
(
Colon,
38..39,
),
(
Lbrace,
39..40,
),
(
Int {
value: 1,
},
40..41,
),
(
Rbrace,
41..42,
),
(
Rbrace,
42..43,
),
(
Rbrace,
43..44,
),
(
FStringEnd,
44..45,
),
(
Newline,
45..45,
),
]

View file

@ -0,0 +1,50 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "foo ",
is_raw: false,
},
2..6,
),
(
Lbrace,
6..7,
),
(
Exclamation,
7..8,
),
(
Name {
name: "pwd",
},
8..11,
),
(
Rbrace,
11..12,
),
(
FStringMiddle {
value: " bar",
is_raw: false,
},
12..16,
),
(
FStringEnd,
16..17,
),
(
Newline,
17..17,
),
]

View file

@ -0,0 +1,110 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
Lbrace,
2..3,
),
(
Lambda,
3..9,
),
(
Name {
name: "x",
},
10..11,
),
(
Colon,
11..12,
),
(
Lbrace,
12..13,
),
(
Name {
name: "x",
},
13..14,
),
(
Rbrace,
14..15,
),
(
Rbrace,
15..16,
),
(
FStringEnd,
16..17,
),
(
Newline,
17..18,
),
(
FStringStart,
18..20,
),
(
Lbrace,
20..21,
),
(
Lpar,
21..22,
),
(
Lambda,
22..28,
),
(
Name {
name: "x",
},
29..30,
),
(
Colon,
30..31,
),
(
Lbrace,
31..32,
),
(
Name {
name: "x",
},
32..33,
),
(
Rbrace,
33..34,
),
(
Rpar,
34..35,
),
(
Rbrace,
35..36,
),
(
FStringEnd,
36..37,
),
(
Newline,
37..37,
),
]

View file

@ -0,0 +1,170 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
Lbrace,
2..3,
),
(
Name {
name: "x",
},
3..4,
),
(
Colon,
4..5,
),
(
FStringMiddle {
value: "=10",
is_raw: false,
},
5..8,
),
(
Rbrace,
8..9,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
9..10,
),
(
Lbrace,
10..11,
),
(
Lpar,
11..12,
),
(
Name {
name: "x",
},
12..13,
),
(
ColonEqual,
13..15,
),
(
Int {
value: 10,
},
15..17,
),
(
Rpar,
17..18,
),
(
Rbrace,
18..19,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
19..20,
),
(
Lbrace,
20..21,
),
(
Name {
name: "x",
},
21..22,
),
(
Comma,
22..23,
),
(
Lbrace,
23..24,
),
(
Name {
name: "y",
},
24..25,
),
(
ColonEqual,
25..27,
),
(
Int {
value: 10,
},
27..29,
),
(
Rbrace,
29..30,
),
(
Rbrace,
30..31,
),
(
FStringMiddle {
value: " ",
is_raw: false,
},
31..32,
),
(
Lbrace,
32..33,
),
(
Lsqb,
33..34,
),
(
Name {
name: "x",
},
34..35,
),
(
ColonEqual,
35..37,
),
(
Int {
value: 10,
},
37..39,
),
(
Rsqb,
39..40,
),
(
Rbrace,
40..41,
),
(
FStringEnd,
41..42,
),
(
Newline,
42..42,
),
]

View file

@ -0,0 +1,25 @@
---
source: crates/ruff_python_parser/src/lexer.rs
expression: lex_source(source)
---
[
(
FStringStart,
0..2,
),
(
FStringMiddle {
value: "\\0",
is_raw: false,
},
2..4,
),
(
FStringEnd,
4..5,
),
(
Newline,
5..5,
),
]

View file

@ -0,0 +1,848 @@
---
source: crates/ruff_python_parser/src/parser.rs
expression: parse_ast
---
[
Expr(
StmtExpr {
range: 0..9,
value: FString(
ExprFString {
range: 0..9,
values: [
FormattedValue(
ExprFormattedValue {
range: 2..8,
value: Constant(
ExprConstant {
range: 3..7,
value: Str(
StringConstant {
value: " f",
unicode: false,
implicit_concatenated: false,
},
),
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 10..20,
value: FString(
ExprFString {
range: 10..20,
values: [
FormattedValue(
ExprFormattedValue {
range: 12..19,
value: Name(
ExprName {
range: 13..16,
id: "foo",
ctx: Load,
},
),
debug_text: None,
conversion: Str,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 21..28,
value: FString(
ExprFString {
range: 21..28,
values: [
FormattedValue(
ExprFormattedValue {
range: 23..27,
value: Tuple(
ExprTuple {
range: 24..26,
elts: [
Constant(
ExprConstant {
range: 24..25,
value: Int(
3,
),
},
),
],
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 29..39,
value: FString(
ExprFString {
range: 29..39,
values: [
FormattedValue(
ExprFormattedValue {
range: 31..38,
value: Compare(
ExprCompare {
range: 32..36,
left: Constant(
ExprConstant {
range: 32..33,
value: Int(
3,
),
},
),
ops: [
NotEq,
],
comparators: [
Constant(
ExprConstant {
range: 35..36,
value: Int(
4,
),
},
),
],
},
),
debug_text: None,
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 37..37,
values: [],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 40..55,
value: FString(
ExprFString {
range: 40..55,
values: [
FormattedValue(
ExprFormattedValue {
range: 42..54,
value: Constant(
ExprConstant {
range: 43..44,
value: Int(
3,
),
},
),
debug_text: None,
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 45..53,
values: [
FormattedValue(
ExprFormattedValue {
range: 45..50,
value: Constant(
ExprConstant {
range: 46..49,
value: Str(
StringConstant {
value: "}",
unicode: false,
implicit_concatenated: false,
},
),
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 50..53,
value: Str(
StringConstant {
value: ">10",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 56..71,
value: FString(
ExprFString {
range: 56..71,
values: [
FormattedValue(
ExprFormattedValue {
range: 58..70,
value: Constant(
ExprConstant {
range: 59..60,
value: Int(
3,
),
},
),
debug_text: None,
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 61..69,
values: [
FormattedValue(
ExprFormattedValue {
range: 61..66,
value: Constant(
ExprConstant {
range: 62..65,
value: Str(
StringConstant {
value: "{",
unicode: false,
implicit_concatenated: false,
},
),
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 66..69,
value: Str(
StringConstant {
value: ">10",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 72..86,
value: FString(
ExprFString {
range: 72..86,
values: [
FormattedValue(
ExprFormattedValue {
range: 74..85,
value: Name(
ExprName {
range: 77..80,
id: "foo",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: " ",
trailing: " = ",
},
),
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 87..107,
value: FString(
ExprFString {
range: 87..107,
values: [
FormattedValue(
ExprFormattedValue {
range: 89..106,
value: Name(
ExprName {
range: 92..95,
id: "foo",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: " ",
trailing: " = ",
},
),
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 100..105,
values: [
Constant(
ExprConstant {
range: 100..105,
value: Str(
StringConstant {
value: ".3f ",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 108..126,
value: FString(
ExprFString {
range: 108..126,
values: [
FormattedValue(
ExprFormattedValue {
range: 110..125,
value: Name(
ExprName {
range: 113..116,
id: "foo",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: " ",
trailing: " = ",
},
),
conversion: Str,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 127..143,
value: FString(
ExprFString {
range: 127..143,
values: [
FormattedValue(
ExprFormattedValue {
range: 129..142,
value: Tuple(
ExprTuple {
range: 132..136,
elts: [
Constant(
ExprConstant {
range: 132..133,
value: Int(
1,
),
},
),
Constant(
ExprConstant {
range: 135..136,
value: Int(
2,
),
},
),
],
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: " ",
trailing: " = ",
},
),
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 144..170,
value: FString(
ExprFString {
range: 144..170,
values: [
FormattedValue(
ExprFormattedValue {
range: 146..169,
value: FString(
ExprFString {
range: 147..163,
values: [
FormattedValue(
ExprFormattedValue {
range: 149..162,
value: Constant(
ExprConstant {
range: 150..156,
value: Float(
3.1415,
),
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 158..161,
values: [
Constant(
ExprConstant {
range: 158..161,
value: Str(
StringConstant {
value: ".1f",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
debug_text: None,
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 164..168,
values: [
Constant(
ExprConstant {
range: 164..168,
value: Str(
StringConstant {
value: "*^20",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 172..206,
value: Dict(
ExprDict {
range: 172..206,
keys: [
Some(
FString(
ExprFString {
range: 173..201,
values: [
Constant(
ExprConstant {
range: 174..186,
value: Str(
StringConstant {
value: "foo bar ",
unicode: false,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 186..193,
value: BinOp(
ExprBinOp {
range: 187..192,
left: Name(
ExprName {
range: 187..188,
id: "x",
ctx: Load,
},
),
op: Add,
right: Name(
ExprName {
range: 191..192,
id: "y",
ctx: Load,
},
),
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 193..200,
value: Str(
StringConstant {
value: " baz",
unicode: false,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
),
],
values: [
Constant(
ExprConstant {
range: 203..205,
value: Int(
10,
),
},
),
],
},
),
},
),
Match(
StmtMatch {
range: 207..269,
subject: Name(
ExprName {
range: 213..216,
id: "foo",
ctx: Load,
},
),
cases: [
MatchCase {
range: 222..269,
pattern: MatchValue(
PatternMatchValue {
range: 227..255,
value: FString(
ExprFString {
range: 227..255,
values: [
Constant(
ExprConstant {
range: 228..240,
value: Str(
StringConstant {
value: "foo bar ",
unicode: false,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 240..247,
value: BinOp(
ExprBinOp {
range: 241..246,
left: Name(
ExprName {
range: 241..242,
id: "x",
ctx: Load,
},
),
op: Add,
right: Name(
ExprName {
range: 245..246,
id: "y",
ctx: Load,
},
),
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 247..254,
value: Str(
StringConstant {
value: " baz",
unicode: false,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
},
),
guard: None,
body: [
Pass(
StmtPass {
range: 265..269,
},
),
],
},
],
},
),
Expr(
StmtExpr {
range: 271..288,
value: FString(
ExprFString {
range: 271..288,
values: [
Constant(
ExprConstant {
range: 273..274,
value: Str(
StringConstant {
value: "\\",
unicode: false,
implicit_concatenated: false,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 274..279,
value: Name(
ExprName {
range: 275..278,
id: "foo",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 279..280,
value: Str(
StringConstant {
value: "\\",
unicode: false,
implicit_concatenated: false,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 280..287,
value: Name(
ExprName {
range: 281..284,
id: "bar",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: Some(
FString(
ExprFString {
range: 285..286,
values: [
Constant(
ExprConstant {
range: 285..286,
value: Str(
StringConstant {
value: "\\",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
),
},
),
],
implicit_concatenated: false,
},
),
},
),
Expr(
StmtExpr {
range: 289..303,
value: FString(
ExprFString {
range: 289..303,
values: [
Constant(
ExprConstant {
range: 291..302,
value: Str(
StringConstant {
value: "\\{foo\\}",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false,
},
),
},
),
]

View file

@ -0,0 +1,214 @@
---
source: crates/ruff_python_parser/src/parser.rs
expression: parse_ast
---
[
Expr(
StmtExpr {
range: 0..29,
value: FString(
ExprFString {
range: 0..29,
values: [
Constant(
ExprConstant {
range: 2..5,
value: Str(
StringConstant {
value: "foo",
unicode: true,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 9..14,
value: Name(
ExprName {
range: 10..13,
id: "bar",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 17..28,
value: Str(
StringConstant {
value: "baz some",
unicode: false,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
},
),
Expr(
StmtExpr {
range: 30..59,
value: FString(
ExprFString {
range: 30..59,
values: [
Constant(
ExprConstant {
range: 31..34,
value: Str(
StringConstant {
value: "foo",
unicode: false,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 38..43,
value: Name(
ExprName {
range: 39..42,
id: "bar",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 47..58,
value: Str(
StringConstant {
value: "baz some",
unicode: true,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
},
),
Expr(
StmtExpr {
range: 60..89,
value: FString(
ExprFString {
range: 60..89,
values: [
Constant(
ExprConstant {
range: 61..64,
value: Str(
StringConstant {
value: "foo",
unicode: false,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 68..73,
value: Name(
ExprName {
range: 69..72,
id: "bar",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 76..88,
value: Str(
StringConstant {
value: "baz some",
unicode: false,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
},
),
Expr(
StmtExpr {
range: 90..128,
value: FString(
ExprFString {
range: 90..128,
values: [
Constant(
ExprConstant {
range: 92..103,
value: Str(
StringConstant {
value: "foobar ",
unicode: true,
implicit_concatenated: true,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 103..108,
value: Name(
ExprName {
range: 104..107,
id: "baz",
ctx: Load,
},
),
debug_text: None,
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 108..127,
value: Str(
StringConstant {
value: " reallybarno",
unicode: false,
implicit_concatenated: true,
},
),
},
),
],
implicit_concatenated: true,
},
),
},
),
]

View file

@ -3,24 +3,37 @@ source: crates/ruff_python_parser/src/string.rs
expression: parse_ast expression: parse_ast
--- ---
[ [
FormattedValue( Expr(
ExprFormattedValue { StmtExpr {
range: 2..9, range: 0..10,
value: Name( value: FString(
ExprName { ExprFString {
range: 3..7, range: 0..10,
id: "user", values: [
ctx: Load, FormattedValue(
ExprFormattedValue {
range: 2..9,
value: Name(
ExprName {
range: 3..7,
id: "user",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false,
}, },
), ),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
}, },
), ),
] ]

View file

@ -3,68 +3,81 @@ source: crates/ruff_python_parser/src/string.rs
expression: parse_ast expression: parse_ast
--- ---
[ [
Constant( Expr(
ExprConstant { StmtExpr {
range: 2..6, range: 0..38,
value: Str( value: FString(
StringConstant { ExprFString {
value: "mix ", range: 0..38,
unicode: false, values: [
Constant(
ExprConstant {
range: 2..6,
value: Str(
StringConstant {
value: "mix ",
unicode: false,
implicit_concatenated: false,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 6..13,
value: Name(
ExprName {
range: 7..11,
id: "user",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 13..28,
value: Str(
StringConstant {
value: " with text and ",
unicode: false,
implicit_concatenated: false,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 28..37,
value: Name(
ExprName {
range: 29..35,
id: "second",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
},
),
],
implicit_concatenated: false, implicit_concatenated: false,
}, },
), ),
}, },
), ),
FormattedValue(
ExprFormattedValue {
range: 6..13,
value: Name(
ExprName {
range: 7..11,
id: "user",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
},
),
Constant(
ExprConstant {
range: 13..28,
value: Str(
StringConstant {
value: " with text and ",
unicode: false,
implicit_concatenated: false,
},
),
},
),
FormattedValue(
ExprFormattedValue {
range: 28..37,
value: Name(
ExprName {
range: 29..35,
id: "second",
ctx: Load,
},
),
debug_text: Some(
DebugText {
leading: "",
trailing: "=",
},
),
conversion: None,
format_spec: None,
},
),
] ]

View file

@ -3,44 +3,57 @@ source: crates/ruff_python_parser/src/string.rs
expression: parse_ast expression: parse_ast
--- ---
[ [
FormattedValue( Expr(
ExprFormattedValue { StmtExpr {
range: 2..13, range: 0..14,
value: Name( value: FString(
ExprName { ExprFString {
range: 3..7, range: 0..14,
id: "user", values: [
ctx: Load, FormattedValue(
}, ExprFormattedValue {
), range: 2..13,
debug_text: Some( value: Name(
DebugText { ExprName {
leading: "", range: 3..7,
trailing: "=", id: "user",
}, ctx: Load,
), },
conversion: None, ),
format_spec: Some( debug_text: Some(
FString( DebugText {
ExprFString { leading: "",
range: 9..12, trailing: "=",
values: [ },
Constant( ),
ExprConstant { conversion: None,
range: 9..12, format_spec: Some(
value: Str( FString(
StringConstant { ExprFString {
value: ">10", range: 9..12,
unicode: false, values: [
Constant(
ExprConstant {
range: 9..12,
value: Str(
StringConstant {
value: ">10",
unicode: false,
implicit_concatenated: false,
},
),
},
),
],
implicit_concatenated: false, implicit_concatenated: false,
}, },
), ),
}, ),
), },
], ),
implicit_concatenated: false, ],
}, implicit_concatenated: false,
), },
), ),
}, },
), ),

View file

@ -1,5 +1,18 @@
--- ---
source: crates/ruff_python_parser/src/string.rs source: crates/ruff_python_parser/src/string.rs
expression: "parse_fstring(\"\").unwrap()" expression: "parse_suite(r#\"f\"\"\"#, \"<test>\").unwrap()"
--- ---
[] [
Expr(
StmtExpr {
range: 0..3,
value: FString(
ExprFString {
range: 0..3,
values: [],
implicit_concatenated: false,
},
),
},
),
]

Some files were not shown because too many files have changed in this diff Show more