|
| 1 | +use anyhow::Result; |
| 2 | +use ruff_text_size::TextRange; |
| 3 | +use rustc_hash::FxHashMap; |
| 4 | + |
1 | 5 | use ruff_diagnostics::{AutofixKind, Diagnostic, Fix, Violation}; |
2 | 6 | use ruff_macros::{derive_message_formats, violation}; |
3 | | -use ruff_python_semantic::binding::Binding; |
| 7 | +use ruff_python_semantic::node::NodeId; |
| 8 | +use ruff_python_semantic::reference::ReferenceId; |
| 9 | +use ruff_python_semantic::scope::Scope; |
4 | 10 |
|
5 | 11 | use crate::autofix; |
6 | 12 | use crate::checkers::ast::Checker; |
7 | | -use crate::importer::StmtImport; |
8 | | -use crate::registry::AsRule; |
| 13 | +use crate::codes::Rule; |
| 14 | +use crate::importer::StmtImports; |
9 | 15 |
|
10 | 16 | /// ## What it does |
11 | 17 | /// Checks for runtime imports defined in a type-checking block. |
@@ -61,72 +67,172 @@ impl Violation for RuntimeImportInTypeCheckingBlock { |
61 | 67 | /// TCH004 |
62 | 68 | pub(crate) fn runtime_import_in_type_checking_block( |
63 | 69 | checker: &Checker, |
64 | | - binding: &Binding, |
| 70 | + scope: &Scope, |
65 | 71 | diagnostics: &mut Vec<Diagnostic>, |
66 | 72 | ) { |
67 | | - let Some(qualified_name) = binding.qualified_name() else { |
68 | | - return; |
69 | | - }; |
| 73 | + // Collect all runtime imports by statement. |
| 74 | + let mut errors_by_statement: FxHashMap<NodeId, Vec<Import>> = FxHashMap::default(); |
| 75 | + let mut ignores_by_statement: FxHashMap<NodeId, Vec<Import>> = FxHashMap::default(); |
70 | 76 |
|
71 | | - let Some(reference_id) = binding.references.first() else { |
72 | | - return; |
73 | | - }; |
| 77 | + for binding_id in scope.binding_ids() { |
| 78 | + let binding = &checker.semantic_model().bindings[binding_id]; |
74 | 79 |
|
75 | | - if binding.context.is_typing() |
76 | | - && binding.references().any(|reference_id| { |
77 | | - checker |
78 | | - .semantic_model() |
79 | | - .references |
80 | | - .resolve(reference_id) |
81 | | - .context() |
82 | | - .is_runtime() |
83 | | - }) |
| 80 | + let Some(qualified_name) = binding.qualified_name() else { |
| 81 | + continue; |
| 82 | + }; |
| 83 | + |
| 84 | + let Some(reference_id) = binding.references.first().copied() else { |
| 85 | + continue; |
| 86 | + }; |
| 87 | + |
| 88 | + if binding.context.is_typing() |
| 89 | + && binding.references().any(|reference_id| { |
| 90 | + checker |
| 91 | + .semantic_model() |
| 92 | + .references |
| 93 | + .resolve(reference_id) |
| 94 | + .context() |
| 95 | + .is_runtime() |
| 96 | + }) |
| 97 | + { |
| 98 | + let Some(stmt_id) = binding.source else { |
| 99 | + continue; |
| 100 | + }; |
| 101 | + |
| 102 | + let import = Import { |
| 103 | + qualified_name, |
| 104 | + reference_id, |
| 105 | + trimmed_range: binding.trimmed_range(checker.semantic_model(), checker.locator), |
| 106 | + parent_range: binding.parent_range(checker.semantic_model()), |
| 107 | + }; |
| 108 | + |
| 109 | + if checker.rule_is_ignored( |
| 110 | + Rule::RuntimeImportInTypeCheckingBlock, |
| 111 | + import.trimmed_range.start(), |
| 112 | + ) || import.parent_range.map_or(false, |parent_range| { |
| 113 | + checker |
| 114 | + .rule_is_ignored(Rule::RuntimeImportInTypeCheckingBlock, parent_range.start()) |
| 115 | + }) { |
| 116 | + ignores_by_statement |
| 117 | + .entry(stmt_id) |
| 118 | + .or_default() |
| 119 | + .push(import); |
| 120 | + } else { |
| 121 | + errors_by_statement.entry(stmt_id).or_default().push(import); |
| 122 | + } |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // Generate a diagnostic for every import, but share a fix across all imports within the same |
| 127 | + // statement (excluding those that are ignored). |
| 128 | + for (stmt_id, imports) in errors_by_statement { |
| 129 | + let fix = if checker.patch(Rule::RuntimeImportInTypeCheckingBlock) { |
| 130 | + fix_imports(checker, stmt_id, &imports).ok() |
| 131 | + } else { |
| 132 | + None |
| 133 | + }; |
| 134 | + |
| 135 | + for Import { |
| 136 | + qualified_name, |
| 137 | + trimmed_range, |
| 138 | + parent_range, |
| 139 | + .. |
| 140 | + } in imports |
| 141 | + { |
| 142 | + let mut diagnostic = Diagnostic::new( |
| 143 | + RuntimeImportInTypeCheckingBlock { |
| 144 | + qualified_name: qualified_name.to_string(), |
| 145 | + }, |
| 146 | + trimmed_range, |
| 147 | + ); |
| 148 | + if let Some(range) = parent_range { |
| 149 | + diagnostic.set_parent(range.start()); |
| 150 | + } |
| 151 | + if let Some(fix) = fix.as_ref() { |
| 152 | + diagnostic.set_fix(fix.clone()); |
| 153 | + } |
| 154 | + diagnostics.push(diagnostic); |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + // Separately, generate a diagnostic for every _ignored_ import, to ensure that the |
| 159 | + // suppression comments aren't marked as unused. |
| 160 | + for Import { |
| 161 | + qualified_name, |
| 162 | + trimmed_range, |
| 163 | + parent_range, |
| 164 | + .. |
| 165 | + } in ignores_by_statement.into_values().flatten() |
84 | 166 | { |
85 | 167 | let mut diagnostic = Diagnostic::new( |
86 | 168 | RuntimeImportInTypeCheckingBlock { |
87 | 169 | qualified_name: qualified_name.to_string(), |
88 | 170 | }, |
89 | | - binding.trimmed_range(checker.semantic_model(), checker.locator), |
| 171 | + trimmed_range, |
90 | 172 | ); |
91 | | - if let Some(range) = binding.parent_range(checker.semantic_model()) { |
| 173 | + if let Some(range) = parent_range { |
92 | 174 | diagnostic.set_parent(range.start()); |
93 | 175 | } |
| 176 | + diagnostics.push(diagnostic); |
| 177 | + } |
| 178 | +} |
94 | 179 |
|
95 | | - if checker.patch(diagnostic.kind.rule()) { |
96 | | - diagnostic.try_set_fix(|| { |
97 | | - // Step 1) Remove the import. |
98 | | - // SAFETY: All non-builtin bindings have a source. |
99 | | - let source = binding.source.unwrap(); |
100 | | - let stmt = checker.semantic_model().stmts[source]; |
101 | | - let parent = checker.semantic_model().stmts.parent(stmt); |
102 | | - let remove_import_edit = autofix::edits::remove_unused_imports( |
103 | | - std::iter::once(qualified_name), |
104 | | - stmt, |
105 | | - parent, |
106 | | - checker.locator, |
107 | | - checker.indexer, |
108 | | - checker.stylist, |
109 | | - )?; |
110 | | - |
111 | | - // Step 2) Add the import to the top-level. |
112 | | - let reference = checker.semantic_model().references.resolve(*reference_id); |
113 | | - let add_import_edit = checker.importer.runtime_import_edit( |
114 | | - &StmtImport { |
115 | | - stmt, |
116 | | - qualified_name, |
117 | | - }, |
118 | | - reference.range().start(), |
119 | | - )?; |
120 | | - |
121 | | - Ok( |
122 | | - Fix::suggested_edits(remove_import_edit, add_import_edit.into_edits()) |
123 | | - .isolate(checker.isolation(parent)), |
124 | | - ) |
125 | | - }); |
126 | | - } |
| 180 | +/// A runtime-required import with its surrounding context. |
| 181 | +struct Import<'a> { |
| 182 | + /// The qualified name of the import (e.g., `typing.List` for `from typing import List`). |
| 183 | + qualified_name: &'a str, |
| 184 | + /// The first reference to the imported symbol. |
| 185 | + reference_id: ReferenceId, |
| 186 | + /// The trimmed range of the import (e.g., `List` in `from typing import List`). |
| 187 | + trimmed_range: TextRange, |
| 188 | + /// The range of the import's parent statement. |
| 189 | + parent_range: Option<TextRange>, |
| 190 | +} |
127 | 191 |
|
128 | | - if checker.enabled(diagnostic.kind.rule()) { |
129 | | - diagnostics.push(diagnostic); |
130 | | - } |
131 | | - } |
| 192 | +/// Generate a [`Fix`] to remove runtime imports from a type-checking block. |
| 193 | +fn fix_imports(checker: &Checker, stmt_id: NodeId, imports: &[Import]) -> Result<Fix> { |
| 194 | + let stmt = checker.semantic_model().stmts[stmt_id]; |
| 195 | + let parent = checker.semantic_model().stmts.parent(stmt); |
| 196 | + let qualified_names: Vec<&str> = imports |
| 197 | + .iter() |
| 198 | + .map(|Import { qualified_name, .. }| *qualified_name) |
| 199 | + .collect(); |
| 200 | + |
| 201 | + // Find the first reference across all imports. |
| 202 | + let at = imports |
| 203 | + .iter() |
| 204 | + .map(|Import { reference_id, .. }| { |
| 205 | + checker |
| 206 | + .semantic_model() |
| 207 | + .references |
| 208 | + .resolve(*reference_id) |
| 209 | + .range() |
| 210 | + .start() |
| 211 | + }) |
| 212 | + .min() |
| 213 | + .expect("Expected at least one import"); |
| 214 | + |
| 215 | + // Step 1) Remove the import. |
| 216 | + let remove_import_edit = autofix::edits::remove_unused_imports( |
| 217 | + qualified_names.iter().copied(), |
| 218 | + stmt, |
| 219 | + parent, |
| 220 | + checker.locator, |
| 221 | + checker.indexer, |
| 222 | + checker.stylist, |
| 223 | + )?; |
| 224 | + |
| 225 | + // Step 2) Add the import to the top-level. |
| 226 | + let add_import_edit = checker.importer.runtime_import_edit( |
| 227 | + &StmtImports { |
| 228 | + stmt, |
| 229 | + qualified_names, |
| 230 | + }, |
| 231 | + at, |
| 232 | + )?; |
| 233 | + |
| 234 | + Ok( |
| 235 | + Fix::suggested_edits(remove_import_edit, add_import_edit.into_edits()) |
| 236 | + .isolate(checker.isolation(parent)), |
| 237 | + ) |
132 | 238 | } |
0 commit comments