|
1 |
| -use ruff_python_ast as ast; |
| 1 | +use ruff_python_ast::{self as ast, Expr}; |
2 | 2 | use ruff_python_codegen::Generator;
|
3 |
| -use ruff_text_size::TextRange; |
| 3 | +use ruff_python_semantic::{BindingId, ResolvedReference, SemanticModel}; |
| 4 | +use ruff_text_size::{Ranged, TextRange}; |
4 | 5 |
|
5 | 6 | /// Format a code snippet to call `name.method()`.
|
6 | 7 | pub(super) fn generate_method_call(name: &str, method: &str, generator: Generator) -> String {
|
@@ -61,3 +62,217 @@ pub(super) fn generate_none_identity_comparison(
|
61 | 62 | };
|
62 | 63 | generator.expr(&compare.into())
|
63 | 64 | }
|
| 65 | + |
| 66 | +// Helpers for read-whole-file and write-whole-file |
| 67 | +#[derive(Debug, Copy, Clone)] |
| 68 | +pub(super) enum OpenMode { |
| 69 | + /// "r" |
| 70 | + ReadText, |
| 71 | + /// "rb" |
| 72 | + ReadBytes, |
| 73 | + /// "w" |
| 74 | + WriteText, |
| 75 | + /// "wb" |
| 76 | + WriteBytes, |
| 77 | +} |
| 78 | + |
| 79 | +impl OpenMode { |
| 80 | + pub(super) fn pathlib_method(self) -> String { |
| 81 | + match self { |
| 82 | + OpenMode::ReadText => "read_text".to_string(), |
| 83 | + OpenMode::ReadBytes => "read_bytes".to_string(), |
| 84 | + OpenMode::WriteText => "write_text".to_string(), |
| 85 | + OpenMode::WriteBytes => "write_bytes".to_string(), |
| 86 | + } |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +/// A grab bag struct that joins together every piece of information we need to track |
| 91 | +/// about a file open operation. |
| 92 | +#[derive(Debug)] |
| 93 | +pub(super) struct FileOpen<'a> { |
| 94 | + /// With item where the open happens, we use it for the reporting range. |
| 95 | + pub(super) item: &'a ast::WithItem, |
| 96 | + /// Filename expression used as the first argument in `open`, we use it in the diagnostic message. |
| 97 | + pub(super) filename: &'a Expr, |
| 98 | + /// The file open mode. |
| 99 | + pub(super) mode: OpenMode, |
| 100 | + /// The file open keywords. |
| 101 | + pub(super) keywords: Vec<&'a ast::Keyword>, |
| 102 | + /// We only check `open` operations whose file handles are used exactly once. |
| 103 | + pub(super) reference: &'a ResolvedReference, |
| 104 | +} |
| 105 | + |
| 106 | +impl<'a> FileOpen<'a> { |
| 107 | + /// Determine whether an expression is a reference to the file handle, by comparing |
| 108 | + /// their ranges. If two expressions have the same range, they must be the same expression. |
| 109 | + pub(super) fn is_ref(&self, expr: &Expr) -> bool { |
| 110 | + expr.range() == self.reference.range() |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/// Find and return all `open` operations in the given `with` statement. |
| 115 | +pub(super) fn find_file_opens<'a>( |
| 116 | + with: &'a ast::StmtWith, |
| 117 | + semantic: &'a SemanticModel<'a>, |
| 118 | + read_mode: bool, |
| 119 | +) -> Vec<FileOpen<'a>> { |
| 120 | + with.items |
| 121 | + .iter() |
| 122 | + .filter_map(|item| find_file_open(item, with, semantic, read_mode)) |
| 123 | + .collect() |
| 124 | +} |
| 125 | + |
| 126 | +/// Find `open` operation in the given `with` item. |
| 127 | +fn find_file_open<'a>( |
| 128 | + item: &'a ast::WithItem, |
| 129 | + with: &'a ast::StmtWith, |
| 130 | + semantic: &'a SemanticModel<'a>, |
| 131 | + read_mode: bool, |
| 132 | +) -> Option<FileOpen<'a>> { |
| 133 | + // We want to match `open(...) as var`. |
| 134 | + let ast::ExprCall { |
| 135 | + func, |
| 136 | + arguments: ast::Arguments { args, keywords, .. }, |
| 137 | + .. |
| 138 | + } = item.context_expr.as_call_expr()?; |
| 139 | + |
| 140 | + if func.as_name_expr()?.id != "open" { |
| 141 | + return None; |
| 142 | + } |
| 143 | + |
| 144 | + let var = item.optional_vars.as_deref()?.as_name_expr()?; |
| 145 | + |
| 146 | + // Ignore calls with `*args` and `**kwargs`. In the exact case of `open(*filename, mode="w")`, |
| 147 | + // it could be a match; but in all other cases, the call _could_ contain unsupported keyword |
| 148 | + // arguments, like `buffering`. |
| 149 | + if args.iter().any(Expr::is_starred_expr) |
| 150 | + || keywords.iter().any(|keyword| keyword.arg.is_none()) |
| 151 | + { |
| 152 | + return None; |
| 153 | + } |
| 154 | + |
| 155 | + // Match positional arguments, get filename and mode. |
| 156 | + let (filename, pos_mode) = match_open_args(args)?; |
| 157 | + |
| 158 | + // Match keyword arguments, get keyword arguments to forward and possibly mode. |
| 159 | + let (keywords, kw_mode) = match_open_keywords(keywords, read_mode)?; |
| 160 | + |
| 161 | + let mode = kw_mode.unwrap_or(pos_mode); |
| 162 | + |
| 163 | + match mode { |
| 164 | + OpenMode::ReadText | OpenMode::ReadBytes => { |
| 165 | + if !read_mode { |
| 166 | + return None; |
| 167 | + } |
| 168 | + } |
| 169 | + OpenMode::WriteText | OpenMode::WriteBytes => { |
| 170 | + if read_mode { |
| 171 | + return None; |
| 172 | + } |
| 173 | + } |
| 174 | + } |
| 175 | + |
| 176 | + // Path.read_bytes and Path.write_bytes do not support any kwargs. |
| 177 | + if matches!(mode, OpenMode::ReadBytes | OpenMode::WriteBytes) && !keywords.is_empty() { |
| 178 | + return None; |
| 179 | + } |
| 180 | + |
| 181 | + // Now we need to find what is this variable bound to... |
| 182 | + let scope = semantic.current_scope(); |
| 183 | + let bindings: Vec<BindingId> = scope.get_all(var.id.as_str()).collect(); |
| 184 | + |
| 185 | + let binding = bindings |
| 186 | + .iter() |
| 187 | + .map(|x| semantic.binding(*x)) |
| 188 | + // We might have many bindings with the same name, but we only care |
| 189 | + // for the one we are looking at right now. |
| 190 | + .find(|binding| binding.range() == var.range())?; |
| 191 | + |
| 192 | + // Since many references can share the same binding, we can limit our attention span |
| 193 | + // exclusively to the body of the current `with` statement. |
| 194 | + let references: Vec<&ResolvedReference> = binding |
| 195 | + .references |
| 196 | + .iter() |
| 197 | + .map(|id| semantic.reference(*id)) |
| 198 | + .filter(|reference| with.range().contains_range(reference.range())) |
| 199 | + .collect(); |
| 200 | + |
| 201 | + // And even with all these restrictions, if the file handle gets used not exactly once, |
| 202 | + // it doesn't fit the bill. |
| 203 | + let [reference] = references.as_slice() else { |
| 204 | + return None; |
| 205 | + }; |
| 206 | + |
| 207 | + Some(FileOpen { |
| 208 | + item, |
| 209 | + filename, |
| 210 | + mode, |
| 211 | + keywords, |
| 212 | + reference, |
| 213 | + }) |
| 214 | +} |
| 215 | + |
| 216 | +/// Match positional arguments. Return expression for the file name and open mode. |
| 217 | +fn match_open_args(args: &[Expr]) -> Option<(&Expr, OpenMode)> { |
| 218 | + match args { |
| 219 | + [filename] => Some((filename, OpenMode::ReadText)), |
| 220 | + [filename, mode_literal] => match_open_mode(mode_literal).map(|mode| (filename, mode)), |
| 221 | + // The third positional argument is `buffering` and the pathlib methods don't support it. |
| 222 | + _ => None, |
| 223 | + } |
| 224 | +} |
| 225 | + |
| 226 | +/// Match keyword arguments. Return keyword arguments to forward and mode. |
| 227 | +fn match_open_keywords( |
| 228 | + keywords: &[ast::Keyword], |
| 229 | + read_mode: bool, |
| 230 | +) -> Option<(Vec<&ast::Keyword>, Option<OpenMode>)> { |
| 231 | + let mut result: Vec<&ast::Keyword> = vec![]; |
| 232 | + let mut mode: Option<OpenMode> = None; |
| 233 | + |
| 234 | + for keyword in keywords { |
| 235 | + match keyword.arg.as_ref()?.as_str() { |
| 236 | + "encoding" | "errors" => result.push(keyword), |
| 237 | + // newline is only valid for write_text |
| 238 | + "newline" => { |
| 239 | + if read_mode { |
| 240 | + return None; |
| 241 | + } |
| 242 | + result.push(keyword); |
| 243 | + } |
| 244 | + |
| 245 | + // This might look bizarre, - why do we re-wrap this optional? |
| 246 | + // |
| 247 | + // The answer is quite simple, in the result of the current function |
| 248 | + // mode being `None` is a possible and correct option meaning that there |
| 249 | + // was NO "mode" keyword argument. |
| 250 | + // |
| 251 | + // The result of `match_open_mode` on the other hand is None |
| 252 | + // in the cases when the mode is not compatible with `write_text`/`write_bytes`. |
| 253 | + // |
| 254 | + // So, here we return None from this whole function if the mode |
| 255 | + // is incompatible. |
| 256 | + "mode" => mode = Some(match_open_mode(&keyword.value)?), |
| 257 | + |
| 258 | + // All other keywords cannot be directly forwarded. |
| 259 | + _ => return None, |
| 260 | + }; |
| 261 | + } |
| 262 | + Some((result, mode)) |
| 263 | +} |
| 264 | + |
| 265 | +/// Match open mode to see if it is supported. |
| 266 | +fn match_open_mode(mode: &Expr) -> Option<OpenMode> { |
| 267 | + let ast::ExprStringLiteral { value, .. } = mode.as_string_literal_expr()?; |
| 268 | + if value.is_implicit_concatenated() { |
| 269 | + return None; |
| 270 | + } |
| 271 | + match value.to_str() { |
| 272 | + "r" => Some(OpenMode::ReadText), |
| 273 | + "rb" => Some(OpenMode::ReadBytes), |
| 274 | + "w" => Some(OpenMode::WriteText), |
| 275 | + "wb" => Some(OpenMode::WriteBytes), |
| 276 | + _ => None, |
| 277 | + } |
| 278 | +} |
0 commit comments