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"))]

0 commit comments

Comments
 (0)