Skip to content

Commit b021b5b

Browse files
authored
Use Tokens from parsed type annotation or parsed source (#11740)
## Summary This PR fixes a bug where the checker would require the tokens for an invalid offset w.r.t. the source code. Taking the source code from the linked issue as an example: ```py relese_version :"0.0is 64" ``` Now, this isn't really a valid type annotation but that's what this PR is fixing. Regardless of whether it's valid or not, Ruff shouldn't panic. The checker would visit the parsed type annotation (`0.0is 64`) and try to detect any violations. Certain rule logic requests the tokens for the same but it would fail because the lexer would only have the `String` token considering original source code. This worked before because the lexer was invoked again for each rule logic. The solution is to store the parsed type annotation on the checker if it's in a typing context and use the tokens from that instead if it's available. This is enforced by creating a new API on the checker to get the tokens. But, this means that there are two ways to get the tokens via the checker API. I want to restrict this in a follow-up PR (#11741) to only expose `tokens` and `comment_ranges` as methods and restrict access to the parsed source code. fixes: #11736 ## Test Plan - [x] Add a test case for `F632` rule and update the snapshot - [x] Check all affected rules - [x] No ecosystem changes
1 parent eed6d78 commit b021b5b

21 files changed

+159
-31
lines changed

crates/ruff_linter/resources/test/fixtures/pyflakes/F632.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@
2929
# Regression test for
3030
if values[1is not None ] is not '-':
3131
pass
32+
33+
# Regression test for https://github.com/astral-sh/ruff/issues/11736
34+
variable: "123 is not y"

crates/ruff_linter/resources/test/fixtures/pyupgrade/UP012.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,7 @@
8080
# Regression test for: https://github.com/astral-sh/ruff/issues/7455#issuecomment-1722459882
8181
def _match_ignore(line):
8282
input=stdin and'\n'.encode()or None
83+
84+
# Not a valid type annotation but this test shouldn't result in a panic.
85+
# Refer: https://github.com/astral-sh/ruff/issues/11736
86+
x: '"foo".encode("utf-8")'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Not a valid type annotation but this test shouldn't result in a panic.
2+
# Refer: https://github.com/astral-sh/ruff/issues/11736
3+
x: 'open("foo", "r")'
4+

crates/ruff_linter/resources/test/fixtures/pyupgrade/UP031_0.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,7 @@
125125
'Hello %s' % bar.baz
126126

127127
'Hello %s' % bar['bop']
128+
129+
# Not a valid type annotation but this test shouldn't result in a panic.
130+
# Refer: https://github.com/astral-sh/ruff/issues/11736
131+
x: "'%s + %s' % (1, 2)"

crates/ruff_linter/resources/test/fixtures/pyupgrade/UP032_0.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,7 @@ async def c():
265265

266266
# The call should be removed, but the string itself should remain.
267267
"".format(self.project)
268+
269+
# Not a valid type annotation but this test shouldn't result in a panic.
270+
# Refer: https://github.com/astral-sh/ruff/issues/11736
271+
x: "'{} + {}'.format(x, y)"

crates/ruff_linter/src/checkers/ast/mod.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ use ruff_python_ast::{helpers, str, visitor, PySourceType};
4949
use ruff_python_codegen::{Generator, Stylist};
5050
use ruff_python_index::Indexer;
5151
use ruff_python_parser::typing::{parse_type_annotation, AnnotationKind};
52-
use ruff_python_parser::Parsed;
52+
use ruff_python_parser::{Parsed, Tokens};
5353
use ruff_python_semantic::all::{DunderAllDefinition, DunderAllFlags};
5454
use ruff_python_semantic::analyze::{imports, typing};
5555
use ruff_python_semantic::{
@@ -176,8 +176,10 @@ impl ExpectedDocstringKind {
176176
}
177177

178178
pub(crate) struct Checker<'a> {
179-
/// The parsed [`Parsed`].
179+
/// The [`Parsed`] output for the source code.
180180
parsed: &'a Parsed<ModModule>,
181+
/// The [`Parsed`] output for the type annotation the checker is currently in.
182+
parsed_type_annotation: Option<&'a Parsed<ModExpression>>,
181183
/// The [`Path`] to the file under analysis.
182184
path: &'a Path,
183185
/// The [`Path`] to the package containing the current file.
@@ -243,6 +245,7 @@ impl<'a> Checker<'a> {
243245
) -> Checker<'a> {
244246
Checker {
245247
parsed,
248+
parsed_type_annotation: None,
246249
settings,
247250
noqa_line_for,
248251
noqa,
@@ -328,6 +331,16 @@ impl<'a> Checker<'a> {
328331
self.parsed
329332
}
330333

334+
/// Returns the [`Tokens`] for the parsed type annotation if the checker is in a typing context
335+
/// or the parsed source code.
336+
pub(crate) fn tokens(&self) -> &'a Tokens {
337+
if let Some(parsed_type_annotation) = self.parsed_type_annotation {
338+
parsed_type_annotation.tokens()
339+
} else {
340+
self.parsed.tokens()
341+
}
342+
}
343+
331344
/// The [`Locator`] for the current file, which enables extraction of source code from byte
332345
/// offsets.
333346
pub(crate) const fn locator(&self) -> &'a Locator<'a> {
@@ -2160,6 +2173,7 @@ impl<'a> Checker<'a> {
21602173
parse_type_annotation(string_expr, self.locator.contents())
21612174
{
21622175
let parsed_annotation = allocator.alloc(parsed_annotation);
2176+
self.parsed_type_annotation = Some(parsed_annotation);
21632177

21642178
let annotation = string_expr.value.to_str();
21652179
let range = string_expr.range();
@@ -2187,6 +2201,7 @@ impl<'a> Checker<'a> {
21872201
self.semantic.flags |=
21882202
SemanticModelFlags::TYPE_DEFINITION | type_definition_flag;
21892203
self.visit_expr(parsed_annotation.expr());
2204+
self.parsed_type_annotation = None;
21902205
} else {
21912206
if self.enabled(Rule::ForwardAnnotationSyntaxError) {
21922207
self.diagnostics.push(Diagnostic::new(

crates/ruff_linter/src/rules/pyflakes/rules/invalid_literal_comparisons.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ pub(crate) fn invalid_literal_comparison(
9696
{
9797
let mut diagnostic = Diagnostic::new(IsLiteral { cmp_op: op.into() }, expr.range());
9898
if lazy_located.is_none() {
99-
lazy_located = Some(locate_cmp_ops(expr, checker.parsed().tokens()));
99+
lazy_located = Some(locate_cmp_ops(expr, checker.tokens()));
100100
}
101101
if let Some(located_op) = lazy_located.as_ref().and_then(|located| located.get(index)) {
102102
assert_eq!(located_op.op, *op);

crates/ruff_linter/src/rules/pyflakes/rules/unused_variable.rs

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -170,11 +170,10 @@ fn remove_unused_variable(binding: &Binding, checker: &Checker) -> Option<Fix> {
170170
)
171171
.unwrap_or(target.range())
172172
.start();
173-
let end =
174-
match_token_after(checker.parsed().tokens(), target.end(), |token| {
175-
token == TokenKind::Equal
176-
})?
177-
.start();
173+
let end = match_token_after(checker.tokens(), target.end(), |token| {
174+
token == TokenKind::Equal
175+
})?
176+
.start();
178177
let edit = Edit::deletion(start, end);
179178
Some(Fix::unsafe_edit(edit))
180179
} else {
@@ -206,10 +205,9 @@ fn remove_unused_variable(binding: &Binding, checker: &Checker) -> Option<Fix> {
206205
// If the expression is complex (`x = foo()`), remove the assignment,
207206
// but preserve the right-hand side.
208207
let start = statement.start();
209-
let end = match_token_after(checker.parsed().tokens(), start, |token| {
210-
token == TokenKind::Equal
211-
})?
212-
.start();
208+
let end =
209+
match_token_after(checker.tokens(), start, |token| token == TokenKind::Equal)?
210+
.start();
213211
let edit = Edit::deletion(start, end);
214212
Some(Fix::unsafe_edit(edit))
215213
} else {
@@ -228,19 +226,17 @@ fn remove_unused_variable(binding: &Binding, checker: &Checker) -> Option<Fix> {
228226
if let Some(optional_vars) = &item.optional_vars {
229227
if optional_vars.range() == binding.range() {
230228
// Find the first token before the `as` keyword.
231-
let start = match_token_before(
232-
checker.parsed().tokens(),
233-
item.context_expr.start(),
234-
|token| token == TokenKind::As,
235-
)?
236-
.end();
229+
let start =
230+
match_token_before(checker.tokens(), item.context_expr.start(), |token| {
231+
token == TokenKind::As
232+
})?
233+
.end();
237234

238235
// Find the first colon, comma, or closing bracket after the `as` keyword.
239-
let end =
240-
match_token_or_closing_brace(checker.parsed().tokens(), start, |token| {
241-
token == TokenKind::Colon || token == TokenKind::Comma
242-
})?
243-
.start();
236+
let end = match_token_or_closing_brace(checker.tokens(), start, |token| {
237+
token == TokenKind::Colon || token == TokenKind::Comma
238+
})?
239+
.start();
244240

245241
let edit = Edit::deletion(start, end);
246242
return Some(Fix::unsafe_edit(edit));

crates/ruff_linter/src/rules/pyflakes/snapshots/ruff_linter__rules__pyflakes__tests__F632_F632.py.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ F632.py:30:4: F632 [*] Use `!=` to compare constant literals
203203
30 |-if values[1is not None ] is not '-':
204204
30 |+if values[1is not None ] != '-':
205205
31 31 | pass
206+
32 32 |
207+
33 33 | # Regression test for https://github.com/astral-sh/ruff/issues/11736
206208

207209
F632.py:30:11: F632 [*] Use `!=` to compare constant literals
208210
|
@@ -220,5 +222,20 @@ F632.py:30:11: F632 [*] Use `!=` to compare constant literals
220222
30 |-if values[1is not None ] is not '-':
221223
30 |+if values[1!= None ] is not '-':
222224
31 31 | pass
225+
32 32 |
226+
33 33 | # Regression test for https://github.com/astral-sh/ruff/issues/11736
223227

228+
F632.py:34:12: F632 [*] Use `!=` to compare constant literals
229+
|
230+
33 | # Regression test for https://github.com/astral-sh/ruff/issues/11736
231+
34 | variable: "123 is not y"
232+
| ^^^^^^^^^^^^ F632
233+
|
234+
= help: Replace `is not` with `!=`
224235

236+
Safe fix
237+
31 31 | pass
238+
32 32 |
239+
33 33 | # Regression test for https://github.com/astral-sh/ruff/issues/11736
240+
34 |-variable: "123 is not y"
241+
34 |+variable: "123 != y"

crates/ruff_linter/src/rules/pyupgrade/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ mod tests {
5858
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_0.py"))]
5959
#[test_case(Rule::QuotedAnnotation, Path::new("UP037_1.py"))]
6060
#[test_case(Rule::RedundantOpenModes, Path::new("UP015.py"))]
61+
#[test_case(Rule::RedundantOpenModes, Path::new("UP015_1.py"))]
6162
#[test_case(Rule::ReplaceStdoutStderr, Path::new("UP022.py"))]
6263
#[test_case(Rule::ReplaceUniversalNewlines, Path::new("UP021.py"))]
6364
#[test_case(Rule::SuperCallWithParameters, Path::new("UP008.py"))]

crates/ruff_linter/src/rules/pyupgrade/rules/deprecated_import.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,7 @@ pub(crate) fn deprecated_import(checker: &mut Checker, import_from_stmt: &StmtIm
719719
module,
720720
checker.locator(),
721721
checker.stylist(),
722-
checker.parsed().tokens(),
722+
checker.tokens(),
723723
checker.settings.target_version,
724724
);
725725

crates/ruff_linter/src/rules/pyupgrade/rules/f_strings.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ pub(crate) fn f_strings(checker: &mut Checker, call: &ast::ExprCall, summary: &F
409409
};
410410

411411
let mut patches: Vec<(TextRange, FStringConversion)> = vec![];
412-
let mut tokens = checker.parsed().tokens().in_range(call.func.range()).iter();
412+
let mut tokens = checker.tokens().in_range(call.func.range()).iter();
413413
let end = loop {
414414
let Some(token) = tokens.next() else {
415415
unreachable!("Should break from the `Tok::Dot` arm");

crates/ruff_linter/src/rules/pyupgrade/rules/printf_string_formatting.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ pub(crate) fn printf_string_formatting(
460460
}
461461

462462
if let Some(prev_end) = prev_end {
463-
for token in checker.parsed().tokens().after(prev_end) {
463+
for token in checker.tokens().after(prev_end) {
464464
match token.kind() {
465465
// If we hit a right paren, we have to preserve it.
466466
TokenKind::Rpar => {

crates/ruff_linter/src/rules/pyupgrade/rules/redundant_open_modes.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pub(crate) fn redundant_open_modes(checker: &mut Checker, call: &ast::ExprCall)
7979
call,
8080
&keyword.value,
8181
mode.replacement_value(),
82-
checker.parsed().tokens(),
82+
checker.tokens(),
8383
));
8484
}
8585
}
@@ -93,7 +93,7 @@ pub(crate) fn redundant_open_modes(checker: &mut Checker, call: &ast::ExprCall)
9393
call,
9494
mode_param,
9595
mode.replacement_value(),
96-
checker.parsed().tokens(),
96+
checker.tokens(),
9797
));
9898
}
9999
}

crates/ruff_linter/src/rules/pyupgrade/rules/unnecessary_encode_utf8.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ pub(crate) fn unnecessary_encode_utf8(checker: &mut Checker, call: &ast::ExprCal
165165
diagnostic.set_fix(replace_with_bytes_literal(
166166
checker.locator(),
167167
call,
168-
checker.parsed().tokens(),
168+
checker.tokens(),
169169
));
170170
checker.diagnostics.push(diagnostic);
171171
} else if let EncodingArg::Keyword(kwarg) = encoding_arg {

crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP012.py.snap

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,8 @@ UP012.py:82:17: UP012 [*] Unnecessary call to `encode` as UTF-8
557557
81 | def _match_ignore(line):
558558
82 | input=stdin and'\n'.encode()or None
559559
| ^^^^^^^^^^^^^ UP012
560+
83 |
561+
84 | # Not a valid type annotation but this test shouldn't result in a panic.
560562
|
561563
= help: Rewrite as bytes literal
562564

@@ -566,5 +568,22 @@ UP012.py:82:17: UP012 [*] Unnecessary call to `encode` as UTF-8
566568
81 81 | def _match_ignore(line):
567569
82 |- input=stdin and'\n'.encode()or None
568570
82 |+ input=stdin and b'\n' or None
571+
83 83 |
572+
84 84 | # Not a valid type annotation but this test shouldn't result in a panic.
573+
85 85 | # Refer: https://github.com/astral-sh/ruff/issues/11736
569574

575+
UP012.py:86:5: UP012 [*] Unnecessary call to `encode` as UTF-8
576+
|
577+
84 | # Not a valid type annotation but this test shouldn't result in a panic.
578+
85 | # Refer: https://github.com/astral-sh/ruff/issues/11736
579+
86 | x: '"foo".encode("utf-8")'
580+
| ^^^^^^^^^^^^^^^^^^^^^ UP012
581+
|
582+
= help: Rewrite as bytes literal
570583

584+
Safe fix
585+
83 83 |
586+
84 84 | # Not a valid type annotation but this test shouldn't result in a panic.
587+
85 85 | # Refer: https://github.com/astral-sh/ruff/issues/11736
588+
86 |-x: '"foo".encode("utf-8")'
589+
86 |+x: 'b"foo"'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: crates/ruff_linter/src/rules/pyupgrade/mod.rs
3+
---
4+
UP015_1.py:3:5: UP015 [*] Unnecessary open mode parameters
5+
|
6+
1 | # Not a valid type annotation but this test shouldn't result in a panic.
7+
2 | # Refer: https://github.com/astral-sh/ruff/issues/11736
8+
3 | x: 'open("foo", "r")'
9+
| ^^^^^^^^^^^^^^^^ UP015
10+
|
11+
= help: Remove open mode parameters
12+
13+
Safe fix
14+
1 1 | # Not a valid type annotation but this test shouldn't result in a panic.
15+
2 2 | # Refer: https://github.com/astral-sh/ruff/issues/11736
16+
3 |-x: 'open("foo", "r")'
17+
3 |+x: 'open("foo")'
18+
4 4 |

crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP031_0.py.snap

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -978,13 +978,16 @@ UP031_0.py:125:1: UP031 [*] Use format specifiers instead of percent format
978978
125 |+'Hello {}'.format(bar.baz)
979979
126 126 |
980980
127 127 | 'Hello %s' % bar['bop']
981+
128 128 |
981982

982983
UP031_0.py:127:1: UP031 [*] Use format specifiers instead of percent format
983984
|
984985
125 | 'Hello %s' % bar.baz
985986
126 |
986987
127 | 'Hello %s' % bar['bop']
987988
| ^^^^^^^^^^^^^^^^^^^^^^^ UP031
989+
128 |
990+
129 | # Not a valid type annotation but this test shouldn't result in a panic.
988991
|
989992
= help: Replace with format specifiers
990993

@@ -994,3 +997,22 @@ UP031_0.py:127:1: UP031 [*] Use format specifiers instead of percent format
994997
126 126 |
995998
127 |-'Hello %s' % bar['bop']
996999
127 |+'Hello {}'.format(bar['bop'])
1000+
128 128 |
1001+
129 129 | # Not a valid type annotation but this test shouldn't result in a panic.
1002+
130 130 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1003+
1004+
UP031_0.py:131:5: UP031 [*] Use format specifiers instead of percent format
1005+
|
1006+
129 | # Not a valid type annotation but this test shouldn't result in a panic.
1007+
130 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1008+
131 | x: "'%s + %s' % (1, 2)"
1009+
| ^^^^^^^^^^^^^^^^^^ UP031
1010+
|
1011+
= help: Replace with format specifiers
1012+
1013+
Unsafe fix
1014+
128 128 |
1015+
129 129 | # Not a valid type annotation but this test shouldn't result in a panic.
1016+
130 130 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1017+
131 |-x: "'%s + %s' % (1, 2)"
1018+
131 |+x: "'{} + {}'.format(1, 2)"

crates/ruff_linter/src/rules/pyupgrade/snapshots/ruff_linter__rules__pyupgrade__tests__UP032_0.py.snap

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1361,6 +1361,8 @@ UP032_0.py:267:1: UP032 [*] Use f-string instead of `format` call
13611361
266 | # The call should be removed, but the string itself should remain.
13621362
267 | "".format(self.project)
13631363
| ^^^^^^^^^^^^^^^^^^^^^^^ UP032
1364+
268 |
1365+
269 | # Not a valid type annotation but this test shouldn't result in a panic.
13641366
|
13651367
= help: Convert to f-string
13661368

@@ -1370,3 +1372,22 @@ UP032_0.py:267:1: UP032 [*] Use f-string instead of `format` call
13701372
266 266 | # The call should be removed, but the string itself should remain.
13711373
267 |-"".format(self.project)
13721374
267 |+""
1375+
268 268 |
1376+
269 269 | # Not a valid type annotation but this test shouldn't result in a panic.
1377+
270 270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1378+
1379+
UP032_0.py:271:5: UP032 [*] Use f-string instead of `format` call
1380+
|
1381+
269 | # Not a valid type annotation but this test shouldn't result in a panic.
1382+
270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1383+
271 | x: "'{} + {}'.format(x, y)"
1384+
| ^^^^^^^^^^^^^^^^^^^^^^ UP032
1385+
|
1386+
= help: Convert to f-string
1387+
1388+
Safe fix
1389+
268 268 |
1390+
269 269 | # Not a valid type annotation but this test shouldn't result in a panic.
1391+
270 270 | # Refer: https://github.com/astral-sh/ruff/issues/11736
1392+
271 |-x: "'{} + {}'.format(x, y)"
1393+
271 |+x: "f'{x} + {y}'"

crates/ruff_linter/src/rules/ruff/rules/sort_dunder_all.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ fn create_fix(
216216
range,
217217
kind,
218218
locator,
219-
checker.parsed().tokens(),
219+
checker.tokens(),
220220
string_items,
221221
)?;
222222
assert_eq!(value.len(), elts.len());

crates/ruff_linter/src/rules/ruff/rules/sort_dunder_slots.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ impl<'a> StringLiteralDisplay<'a> {
210210
self.range(),
211211
*sequence_kind,
212212
locator,
213-
checker.parsed().tokens(),
213+
checker.tokens(),
214214
elements,
215215
)?;
216216
assert_eq!(analyzed_sequence.len(), self.elts.len());

0 commit comments

Comments
 (0)