|
| 1 | +use clippy_utils::diagnostics::span_lint_and_then; |
| 2 | +use clippy_utils::macros::format_arg_removal_span; |
| 3 | +use clippy_utils::source::SpanRangeExt; |
| 4 | +use clippy_utils::sym; |
| 5 | +use rustc_ast::token::LitKind; |
| 6 | +use rustc_ast::{ |
| 7 | + FormatArgPosition, FormatArgPositionKind, FormatArgs, FormatArgsPiece, FormatCount, FormatOptions, |
| 8 | + FormatPlaceholder, FormatTrait, |
| 9 | +}; |
| 10 | +use rustc_errors::Applicability; |
| 11 | +use rustc_lint::LateContext; |
| 12 | +use rustc_span::Span; |
| 13 | + |
| 14 | +use super::{PRINT_LITERAL, WRITE_LITERAL}; |
| 15 | + |
| 16 | +pub(super) fn check(cx: &LateContext<'_>, format_args: &FormatArgs, name: &str) { |
| 17 | + let arg_index = |argument: &FormatArgPosition| argument.index.unwrap_or_else(|pos| pos); |
| 18 | + |
| 19 | + let lint_name = if name.starts_with("write") { |
| 20 | + WRITE_LITERAL |
| 21 | + } else { |
| 22 | + PRINT_LITERAL |
| 23 | + }; |
| 24 | + |
| 25 | + let mut counts = vec![0u32; format_args.arguments.all_args().len()]; |
| 26 | + for piece in &format_args.template { |
| 27 | + if let FormatArgsPiece::Placeholder(placeholder) = piece { |
| 28 | + counts[arg_index(&placeholder.argument)] += 1; |
| 29 | + } |
| 30 | + } |
| 31 | + |
| 32 | + let mut suggestion: Vec<(Span, String)> = vec![]; |
| 33 | + // holds index of replaced positional arguments; used to decrement the index of the remaining |
| 34 | + // positional arguments. |
| 35 | + let mut replaced_position: Vec<usize> = vec![]; |
| 36 | + let mut sug_span: Option<Span> = None; |
| 37 | + |
| 38 | + for piece in &format_args.template { |
| 39 | + if let FormatArgsPiece::Placeholder(FormatPlaceholder { |
| 40 | + argument, |
| 41 | + span: Some(placeholder_span), |
| 42 | + format_trait: FormatTrait::Display, |
| 43 | + format_options, |
| 44 | + }) = piece |
| 45 | + && *format_options == FormatOptions::default() |
| 46 | + && let index = arg_index(argument) |
| 47 | + && counts[index] == 1 |
| 48 | + && let Some(arg) = format_args.arguments.by_index(index) |
| 49 | + && let rustc_ast::ExprKind::Lit(lit) = &arg.expr.kind |
| 50 | + && !arg.expr.span.from_expansion() |
| 51 | + && let Some(value_string) = arg.expr.span.get_source_text(cx) |
| 52 | + { |
| 53 | + let (replacement, replace_raw) = match lit.kind { |
| 54 | + LitKind::Str | LitKind::StrRaw(_) => match extract_str_literal(&value_string) { |
| 55 | + Some(extracted) => extracted, |
| 56 | + None => return, |
| 57 | + }, |
| 58 | + LitKind::Char => ( |
| 59 | + match lit.symbol { |
| 60 | + sym::DOUBLE_QUOTE => "\\\"", |
| 61 | + sym::BACKSLASH_SINGLE_QUOTE => "'", |
| 62 | + _ => match value_string.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) { |
| 63 | + Some(stripped) => stripped, |
| 64 | + None => return, |
| 65 | + }, |
| 66 | + } |
| 67 | + .to_string(), |
| 68 | + false, |
| 69 | + ), |
| 70 | + LitKind::Bool => (lit.symbol.to_string(), false), |
| 71 | + _ => continue, |
| 72 | + }; |
| 73 | + |
| 74 | + let Some(format_string_snippet) = format_args.span.get_source_text(cx) else { |
| 75 | + continue; |
| 76 | + }; |
| 77 | + let format_string_is_raw = format_string_snippet.starts_with('r'); |
| 78 | + |
| 79 | + let replacement = match (format_string_is_raw, replace_raw) { |
| 80 | + (false, false) => Some(replacement), |
| 81 | + (false, true) => Some(replacement.replace('\\', "\\\\").replace('"', "\\\"")), |
| 82 | + (true, false) => match conservative_unescape(&replacement) { |
| 83 | + Ok(unescaped) => Some(unescaped), |
| 84 | + Err(UnescapeErr::Lint) => None, |
| 85 | + Err(UnescapeErr::Ignore) => continue, |
| 86 | + }, |
| 87 | + (true, true) => { |
| 88 | + if replacement.contains(['#', '"']) { |
| 89 | + None |
| 90 | + } else { |
| 91 | + Some(replacement) |
| 92 | + } |
| 93 | + }, |
| 94 | + }; |
| 95 | + |
| 96 | + sug_span = Some(sug_span.unwrap_or(arg.expr.span).to(arg.expr.span)); |
| 97 | + |
| 98 | + if let Some((_, index)) = format_arg_piece_span(piece) { |
| 99 | + replaced_position.push(index); |
| 100 | + } |
| 101 | + |
| 102 | + if let Some(replacement) = replacement |
| 103 | + // `format!("{}", "a")`, `format!("{named}", named = "b") |
| 104 | + // ~~~~~ ~~~~~~~~~~~~~ |
| 105 | + && let Some(removal_span) = format_arg_removal_span(format_args, index) |
| 106 | + { |
| 107 | + let replacement = escape_braces(&replacement, !format_string_is_raw && !replace_raw); |
| 108 | + suggestion.push((*placeholder_span, replacement)); |
| 109 | + suggestion.push((removal_span, String::new())); |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + // Decrement the index of the remaining by the number of replaced positional arguments |
| 115 | + if !suggestion.is_empty() { |
| 116 | + for piece in &format_args.template { |
| 117 | + relocalize_format_args_indexes(piece, &mut suggestion, &replaced_position); |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + if let Some(span) = sug_span { |
| 122 | + span_lint_and_then(cx, lint_name, span, "literal with an empty format string", |diag| { |
| 123 | + if !suggestion.is_empty() { |
| 124 | + diag.multipart_suggestion("try", suggestion, Applicability::MachineApplicable); |
| 125 | + } |
| 126 | + }); |
| 127 | + } |
| 128 | +} |
| 129 | + |
| 130 | +/// Extract Span and its index from the given `piece` |
| 131 | +fn format_arg_piece_span(piece: &FormatArgsPiece) -> Option<(Span, usize)> { |
| 132 | + match piece { |
| 133 | + FormatArgsPiece::Placeholder(FormatPlaceholder { |
| 134 | + argument: FormatArgPosition { index: Ok(index), .. }, |
| 135 | + span: Some(span), |
| 136 | + .. |
| 137 | + }) => Some((*span, *index)), |
| 138 | + _ => None, |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +/// Relocalizes the indexes of positional arguments in the format string |
| 143 | +fn relocalize_format_args_indexes( |
| 144 | + piece: &FormatArgsPiece, |
| 145 | + suggestion: &mut Vec<(Span, String)>, |
| 146 | + replaced_position: &[usize], |
| 147 | +) { |
| 148 | + if let FormatArgsPiece::Placeholder(FormatPlaceholder { |
| 149 | + argument: |
| 150 | + FormatArgPosition { |
| 151 | + index: Ok(index), |
| 152 | + // Only consider positional arguments |
| 153 | + kind: FormatArgPositionKind::Number, |
| 154 | + span: Some(span), |
| 155 | + }, |
| 156 | + format_options, |
| 157 | + .. |
| 158 | + }) = piece |
| 159 | + { |
| 160 | + if suggestion.iter().any(|(s, _)| s.overlaps(*span)) { |
| 161 | + // If the span is already in the suggestion, we don't need to process it again |
| 162 | + return; |
| 163 | + } |
| 164 | + |
| 165 | + // lambda to get the decremented index based on the replaced positions |
| 166 | + let decremented_index = |index: usize| -> usize { |
| 167 | + let decrement = replaced_position.iter().filter(|&&i| i < index).count(); |
| 168 | + index - decrement |
| 169 | + }; |
| 170 | + |
| 171 | + suggestion.push((*span, decremented_index(*index).to_string())); |
| 172 | + |
| 173 | + // If there are format options, we need to handle them as well |
| 174 | + if *format_options != FormatOptions::default() { |
| 175 | + // lambda to process width and precision format counts and add them to the suggestion |
| 176 | + let mut process_format_count = |count: &Option<FormatCount>, formatter: &dyn Fn(usize) -> String| { |
| 177 | + if let Some(FormatCount::Argument(FormatArgPosition { |
| 178 | + index: Ok(format_arg_index), |
| 179 | + kind: FormatArgPositionKind::Number, |
| 180 | + span: Some(format_arg_span), |
| 181 | + })) = count |
| 182 | + { |
| 183 | + suggestion.push((*format_arg_span, formatter(decremented_index(*format_arg_index)))); |
| 184 | + } |
| 185 | + }; |
| 186 | + |
| 187 | + process_format_count(&format_options.width, &|index: usize| format!("{index}$")); |
| 188 | + process_format_count(&format_options.precision, &|index: usize| format!(".{index}$")); |
| 189 | + } |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +/// Removes the raw marker, `#`s and quotes from a str, and returns if the literal is raw |
| 194 | +/// |
| 195 | +/// `r#"a"#` -> (`a`, true) |
| 196 | +/// |
| 197 | +/// `"b"` -> (`b`, false) |
| 198 | +fn extract_str_literal(literal: &str) -> Option<(String, bool)> { |
| 199 | + let (literal, raw) = match literal.strip_prefix('r') { |
| 200 | + Some(stripped) => (stripped.trim_matches('#'), true), |
| 201 | + None => (literal, false), |
| 202 | + }; |
| 203 | + |
| 204 | + Some((literal.strip_prefix('"')?.strip_suffix('"')?.to_string(), raw)) |
| 205 | +} |
| 206 | + |
| 207 | +enum UnescapeErr { |
| 208 | + /// Should still be linted, can be manually resolved by author, e.g. |
| 209 | + /// |
| 210 | + /// ```ignore |
| 211 | + /// print!(r"{}", '"'); |
| 212 | + /// ``` |
| 213 | + Lint, |
| 214 | + /// Should not be linted, e.g. |
| 215 | + /// |
| 216 | + /// ```ignore |
| 217 | + /// print!(r"{}", '\r'); |
| 218 | + /// ``` |
| 219 | + Ignore, |
| 220 | +} |
| 221 | + |
| 222 | +/// Unescape a normal string into a raw string |
| 223 | +fn conservative_unescape(literal: &str) -> Result<String, UnescapeErr> { |
| 224 | + let mut unescaped = String::with_capacity(literal.len()); |
| 225 | + let mut chars = literal.chars(); |
| 226 | + let mut err = false; |
| 227 | + |
| 228 | + while let Some(ch) = chars.next() { |
| 229 | + match ch { |
| 230 | + '#' => err = true, |
| 231 | + '\\' => match chars.next() { |
| 232 | + Some('\\') => unescaped.push('\\'), |
| 233 | + Some('"') => err = true, |
| 234 | + _ => return Err(UnescapeErr::Ignore), |
| 235 | + }, |
| 236 | + _ => unescaped.push(ch), |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + if err { Err(UnescapeErr::Lint) } else { Ok(unescaped) } |
| 241 | +} |
| 242 | + |
| 243 | +/// Replaces `{` with `{{` and `}` with `}}`. If `preserve_unicode_escapes` is `true` the braces |
| 244 | +/// in `\u{xxxx}` are left unmodified |
| 245 | +#[expect(clippy::match_same_arms)] |
| 246 | +fn escape_braces(literal: &str, preserve_unicode_escapes: bool) -> String { |
| 247 | + #[derive(Clone, Copy)] |
| 248 | + enum State { |
| 249 | + Normal, |
| 250 | + Backslash, |
| 251 | + UnicodeEscape, |
| 252 | + } |
| 253 | + |
| 254 | + let mut escaped = String::with_capacity(literal.len()); |
| 255 | + let mut state = State::Normal; |
| 256 | + |
| 257 | + for ch in literal.chars() { |
| 258 | + state = match (ch, state) { |
| 259 | + // Escape braces outside of unicode escapes by doubling them up |
| 260 | + ('{' | '}', State::Normal) => { |
| 261 | + escaped.push(ch); |
| 262 | + State::Normal |
| 263 | + }, |
| 264 | + // If `preserve_unicode_escapes` isn't enabled stay in `State::Normal`, otherwise: |
| 265 | + // |
| 266 | + // \u{aaaa} \\ \x01 |
| 267 | + // ^ ^ ^ |
| 268 | + ('\\', State::Normal) if preserve_unicode_escapes => State::Backslash, |
| 269 | + // \u{aaaa} |
| 270 | + // ^ |
| 271 | + ('u', State::Backslash) => State::UnicodeEscape, |
| 272 | + // \xAA \\ |
| 273 | + // ^ ^ |
| 274 | + (_, State::Backslash) => State::Normal, |
| 275 | + // \u{aaaa} |
| 276 | + // ^ |
| 277 | + ('}', State::UnicodeEscape) => State::Normal, |
| 278 | + _ => state, |
| 279 | + }; |
| 280 | + |
| 281 | + escaped.push(ch); |
| 282 | + } |
| 283 | + |
| 284 | + escaped |
| 285 | +} |
0 commit comments