Skip to content

Commit aa93005

Browse files
authored
Control flow graph: setup (#17064)
This PR contains the scaffolding for a new control flow graph implementation, along with its application to the `unreachable` rule. At the moment, the implementation is a maximal over-approximation: no control flow is modeled and all statements are counted as reachable. With each additional statement type we support, this approximation will improve. So this PR just contains: - A `ControlFlowGraph` struct and builder - Support for printing the flow graph as a Mermaid graph - Snapshot tests for the actual graphs - (a very bad!) reimplementation of `unreachable` using the new structs - Snapshot tests for `unreachable` # Instructions for Viewing Mermaid snapshots Unfortunately I don't know how to convince GitHub to render the Mermaid graphs in the snapshots. However, you can view these locally in VSCode if you install an extension that supports Mermaid graphs in Markdown, and then add this to your `settings.json`: ```json "files.associations": { "*.md.snap": "markdown", } ```
1 parent 0073fd4 commit aa93005

24 files changed

+777
-6246
lines changed

Cargo.lock

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ruff_linter/Cargo.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ license = { workspace = true }
1616
ruff_annotate_snippets = { workspace = true }
1717
ruff_cache = { workspace = true }
1818
ruff_diagnostics = { workspace = true, features = ["serde"] }
19-
ruff_index = { workspace = true }
2019
ruff_notebook = { workspace = true }
2120
ruff_macros = { workspace = true }
2221
ruff_python_ast = { workspace = true, features = ["serde", "cache"] }
Lines changed: 13 additions & 262 deletions
Original file line numberDiff line numberDiff line change
@@ -1,263 +1,14 @@
1-
def after_return():
2-
return "reachable"
3-
return "unreachable"
4-
5-
async def also_works_on_async_functions():
6-
return "reachable"
7-
return "unreachable"
8-
9-
def if_always_true():
10-
if True:
11-
return "reachable"
12-
return "unreachable"
13-
14-
def if_always_false():
15-
if False:
16-
return "unreachable"
17-
return "reachable"
18-
19-
def if_elif_always_false():
20-
if False:
21-
return "unreachable"
22-
elif False:
23-
return "also unreachable"
24-
return "reachable"
25-
26-
def if_elif_always_true():
27-
if False:
28-
return "unreachable"
29-
elif True:
30-
return "reachable"
31-
return "also unreachable"
32-
33-
def ends_with_if():
34-
if False:
35-
return "unreachable"
36-
else:
37-
return "reachable"
38-
39-
def infinite_loop():
40-
while True:
41-
continue
42-
return "unreachable"
43-
44-
''' TODO: we could determine these, but we don't yet.
45-
def for_range_return():
46-
for i in range(10):
47-
if i == 5:
48-
return "reachable"
49-
return "unreachable"
50-
51-
def for_range_else():
52-
for i in range(111):
53-
if i == 5:
54-
return "reachable"
55-
else:
56-
return "unreachable"
57-
return "also unreachable"
58-
59-
def for_range_break():
60-
for i in range(13):
61-
return "reachable"
62-
return "unreachable"
63-
64-
def for_range_if_break():
65-
for i in range(1110):
66-
if True:
67-
return "reachable"
68-
return "unreachable"
69-
'''
70-
71-
def match_wildcard(status):
72-
match status:
73-
case _:
74-
return "reachable"
75-
return "unreachable"
76-
77-
def match_case_and_wildcard(status):
78-
match status:
79-
case 1:
80-
return "reachable"
81-
case _:
82-
return "reachable"
83-
return "unreachable"
84-
85-
def raise_exception():
86-
raise Exception
87-
return "unreachable"
88-
89-
def while_false():
90-
while False:
91-
return "unreachable"
92-
return "reachable"
93-
94-
def while_false_else():
95-
while False:
96-
return "unreachable"
97-
else:
98-
return "reachable"
99-
100-
def while_false_else_return():
101-
while False:
102-
return "unreachable"
103-
else:
104-
return "reachable"
105-
return "also unreachable"
106-
107-
def while_true():
108-
while True:
109-
return "reachable"
110-
return "unreachable"
111-
112-
def while_true_else():
113-
while True:
114-
return "reachable"
115-
else:
116-
return "unreachable"
117-
118-
def while_true_else_return():
119-
while True:
120-
return "reachable"
121-
else:
122-
return "unreachable"
123-
return "also unreachable"
124-
125-
def while_false_var_i():
126-
i = 0
127-
while False:
128-
i += 1
129-
return i
130-
131-
def while_true_var_i():
132-
i = 0
133-
while True:
134-
i += 1
135-
return i
136-
137-
def while_infinite():
138-
while True:
139-
pass
140-
return "unreachable"
141-
142-
def while_if_true():
143-
while True:
144-
if True:
145-
return "reachable"
146-
return "unreachable"
147-
148-
def while_break():
149-
while True:
150-
print("reachable")
151-
break
152-
print("unreachable")
153-
return "reachable"
154-
155-
# Test case found in the Bokeh repository that triggered a false positive.
156-
def bokeh1(self, obj: BytesRep) -> bytes:
157-
data = obj["data"]
158-
159-
if isinstance(data, str):
160-
return base64.b64decode(data)
161-
elif isinstance(data, Buffer):
162-
buffer = data
163-
else:
164-
id = data["id"]
165-
166-
if id in self._buffers:
167-
buffer = self._buffers[id]
168-
else:
169-
self.error(f"can't resolve buffer '{id}'")
170-
171-
return buffer.data
172-
173-
# Test case found in the Bokeh repository that triggered a false positive.
174-
def bokeh2(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None:
175-
self.stop_serving = False
176-
while True:
177-
try:
178-
self.server = HTTPServer((host, port), HtmlOnlyHandler)
179-
self.host = host
180-
self.port = port
181-
break
182-
except OSError:
183-
log.debug(f"port {port} is in use, trying to next one")
184-
port += 1
185-
186-
self.thread = threading.Thread(target=self._run_web_server)
187-
188-
# Test case found in the pandas repository that triggered a false positive.
189-
def _check_basic_constructor(self, empty):
190-
# mat: 2d matrix with shape (3, 2) to input. empty - makes sized
191-
# objects
192-
mat = empty((2, 3), dtype=float)
193-
# 2-D input
194-
frame = DataFrame(mat, columns=["A", "B", "C"], index=[1, 2])
195-
196-
assert len(frame.index) == 2
197-
assert len(frame.columns) == 3
198-
199-
# 1-D input
200-
frame = DataFrame(empty((3,)), columns=["A"], index=[1, 2, 3])
201-
assert len(frame.index) == 3
202-
assert len(frame.columns) == 1
203-
204-
if empty is not np.ones:
205-
msg = r"Cannot convert non-finite values \(NA or inf\) to integer"
206-
with pytest.raises(IntCastingNaNError, match=msg):
207-
DataFrame(mat, columns=["A", "B", "C"], index=[1, 2], dtype=np.int64)
1+
def empty_statement_reachable(): ...
2+
3+
def pass_statement_reachable():
4+
pass
5+
6+
def no_control_flow_reachable():
7+
x = 1
8+
x = 2
9+
class C:
10+
a = 2
11+
c = C()
12+
del c
13+
def foo():
20814
return
209-
else:
210-
frame = DataFrame(
211-
mat, columns=["A", "B", "C"], index=[1, 2], dtype=np.int64
212-
)
213-
assert frame.values.dtype == np.int64
214-
215-
# wrong size axis labels
216-
msg = r"Shape of passed values is \(2, 3\), indices imply \(1, 3\)"
217-
with pytest.raises(ValueError, match=msg):
218-
DataFrame(mat, columns=["A", "B", "C"], index=[1])
219-
msg = r"Shape of passed values is \(2, 3\), indices imply \(2, 2\)"
220-
with pytest.raises(ValueError, match=msg):
221-
DataFrame(mat, columns=["A", "B"], index=[1, 2])
222-
223-
# higher dim raise exception
224-
with pytest.raises(ValueError, match="Must pass 2-d input"):
225-
DataFrame(empty((3, 3, 3)), columns=["A", "B", "C"], index=[1])
226-
227-
# automatic labeling
228-
frame = DataFrame(mat)
229-
tm.assert_index_equal(frame.index, Index(range(2)), exact=True)
230-
tm.assert_index_equal(frame.columns, Index(range(3)), exact=True)
231-
232-
frame = DataFrame(mat, index=[1, 2])
233-
tm.assert_index_equal(frame.columns, Index(range(3)), exact=True)
234-
235-
frame = DataFrame(mat, columns=["A", "B", "C"])
236-
tm.assert_index_equal(frame.index, Index(range(2)), exact=True)
237-
238-
# 0-length axis
239-
frame = DataFrame(empty((0, 3)))
240-
assert len(frame.index) == 0
241-
242-
frame = DataFrame(empty((3, 0)))
243-
assert len(frame.columns) == 0
244-
245-
246-
def after_return():
247-
return "reachable"
248-
print("unreachable")
249-
print("unreachable")
250-
print("unreachable")
251-
print("unreachable")
252-
print("unreachable")
253-
254-
255-
def check_if_url_exists(url: str) -> bool: # type: ignore[return]
256-
return True # uncomment to check URLs
257-
response = requests.head(url, allow_redirects=True)
258-
if response.status_code == 200:
259-
return True
260-
if response.status_code == 404:
261-
return False
262-
console.print(f"[red]Unexpected error received: {response.status_code}[/]")
263-
response.raise_for_status()

crates/ruff_linter/src/checkers/ast/analyze/statement.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ pub(crate) fn statement(stmt: &Stmt, checker: &mut Checker) {
349349
}
350350
#[cfg(any(feature = "test-rules", test))]
351351
if checker.enabled(Rule::UnreachableCode) {
352-
checker.report_diagnostics(pylint::rules::in_function(name, body));
352+
pylint::rules::in_function(checker, name, body);
353353
}
354354
if checker.enabled(Rule::ReimplementedOperator) {
355355
refurb::rules::reimplemented_operator(checker, &function_def.into());

0 commit comments

Comments
 (0)