cargo_quality/analyzers/
format_args.rs1use masterror::AppResult;
5use proc_macro2::TokenTree;
6use syn::{ExprMacro, File, LitStr, Macro, spanned::Spanned};
7
8use crate::analyzer::{AnalysisResult, Analyzer, Fix, Issue};
9
10pub struct FormatArgsAnalyzer;
12
13impl FormatArgsAnalyzer {
14 #[inline]
15 pub fn new() -> Self {
16 Self
17 }
18
19 fn analyze_format_macro(mac: &Macro) -> Option<Issue> {
20 let format = Self::extract_format_string(mac)?;
21 let placeholder_count = Self::count_positional_placeholders(&format);
22
23 if placeholder_count >= 3 {
24 let span = mac.span();
25 let start = span.start();
26
27 return Some(Issue {
28 line: start.line,
29 column: start.column,
30 message: format!(
31 "Use named format arguments for better readability ({} placeholders)",
32 placeholder_count
33 ),
34 fix: Fix::None
35 });
36 }
37
38 None
39 }
40
41 fn extract_format_string(mac: &Macro) -> Option<String> {
55 for token in mac.tokens.clone() {
56 if let TokenTree::Literal(literal) = token
57 && let Ok(lit_str) = syn::parse_str::<LitStr>(&literal.to_string())
58 {
59 return Some(lit_str.value());
60 }
61 }
62
63 None
64 }
65
66 fn count_positional_placeholders(format: &str) -> usize {
80 let bytes = format.as_bytes();
81 let mut count = 0;
82 let mut index = 0;
83
84 while index < bytes.len() {
85 match bytes[index] {
86 b'{' => {
87 if bytes.get(index + 1) == Some(&b'{') {
88 index += 2;
89 continue;
90 }
91
92 let name_start = index + 1;
93 let mut name_end = name_start;
94 while name_end < bytes.len()
95 && bytes[name_end] != b'}'
96 && bytes[name_end] != b':'
97 {
98 name_end += 1;
99 }
100
101 let name = &format[name_start..name_end];
102 if name.is_empty() || name.bytes().all(|b| b.is_ascii_digit()) {
103 count += 1;
104 }
105
106 while index < bytes.len() && bytes[index] != b'}' {
107 index += 1;
108 }
109 index += 1;
110 }
111 b'}' => {
112 index += if bytes.get(index + 1) == Some(&b'}') {
113 2
114 } else {
115 1
116 };
117 }
118 _ => index += 1
119 }
120 }
121
122 count
123 }
124}
125
126impl Analyzer for FormatArgsAnalyzer {
127 fn name(&self) -> &'static str {
128 "format_args"
129 }
130
131 fn analyze(&self, ast: &File, _content: &str) -> AppResult<AnalysisResult> {
132 let mut visitor = FormatVisitor {
133 issues: Vec::new()
134 };
135 syn::visit::visit_file(&mut visitor, ast);
136
137 Ok(AnalysisResult {
138 issues: visitor.issues,
139 fixable_count: 0
140 })
141 }
142}
143
144struct FormatVisitor {
145 issues: Vec<Issue>
146}
147
148impl<'ast> syn::visit::Visit<'ast> for FormatVisitor {
149 fn visit_expr_macro(&mut self, node: &'ast ExprMacro) {
150 self.check_macro(&node.mac);
151 syn::visit::visit_expr_macro(self, node);
152 }
153
154 fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) {
155 self.check_macro(&node.mac);
156 syn::visit::visit_stmt_macro(self, node);
157 }
158}
159
160impl FormatVisitor {
161 fn check_macro(&mut self, mac: &Macro) {
162 let path = &mac.path;
163
164 if (path.is_ident("format")
165 || path.is_ident("println")
166 || path.is_ident("print")
167 || path.is_ident("write")
168 || path.is_ident("writeln"))
169 && let Some(issue) = FormatArgsAnalyzer::analyze_format_macro(mac)
170 {
171 self.issues.push(issue);
172 }
173 }
174}
175
176impl Default for FormatArgsAnalyzer {
177 fn default() -> Self {
178 Self::new()
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use syn::parse_quote;
185
186 use super::*;
187
188 #[test]
189 fn test_analyzer_name() {
190 let analyzer = FormatArgsAnalyzer::new();
191 assert_eq!(analyzer.name(), "format_args");
192 }
193
194 #[test]
195 fn test_detect_positional_args() {
196 let analyzer = FormatArgsAnalyzer::new();
197 let code: File = parse_quote! {
198 fn main() {
199 println!("Values: {} {} {}", 1, 2, 3);
200 }
201 };
202
203 let result = analyzer.analyze(&code, "").unwrap();
204 assert!(!result.issues.is_empty());
205 }
206
207 #[test]
208 fn test_advisory_only_not_fixable() {
209 let analyzer = FormatArgsAnalyzer::new();
210 let code: File = parse_quote! {
211 fn main() {
212 println!("Values: {} {} {}", 1, 2, 3);
213 }
214 };
215
216 let result = analyzer.analyze(&code, "").unwrap();
217 assert!(!result.issues.is_empty());
218 assert_eq!(result.fixable_count, 0);
219 assert!(!result.issues[0].fix.is_available());
220 }
221
222 #[test]
223 fn test_ignore_simple_positional() {
224 let analyzer = FormatArgsAnalyzer::new();
225 let code: File = parse_quote! {
226 fn main() {
227 println!("Value: {}", 42);
228 println!("Two: {} {}", 1, 2);
229 }
230 };
231
232 let result = analyzer.analyze(&code, "").unwrap();
233 assert_eq!(result.issues.len(), 0);
234 }
235
236 #[test]
237 fn test_ignore_named_args() {
238 let analyzer = FormatArgsAnalyzer::new();
239 let code: File = parse_quote! {
240 fn main() {
241 let name = "World";
242 println!("Hello {name}");
243 }
244 };
245
246 let result = analyzer.analyze(&code, "").unwrap();
247 assert_eq!(result.issues.len(), 0);
248 }
249
250 #[test]
251 fn test_detect_format_macro() {
252 let analyzer = FormatArgsAnalyzer::new();
253 let code: File = parse_quote! {
254 fn main() {
255 let msg = format!("Values: {} {} {}", 1, 2, 3);
256 }
257 };
258
259 let result = analyzer.analyze(&code, "").unwrap();
260 assert!(!result.issues.is_empty());
261 }
262
263 #[test]
264 fn test_detect_print_macro() {
265 let analyzer = FormatArgsAnalyzer::new();
266 let code: File = parse_quote! {
267 fn main() {
268 print!("Values: {} {} {}", 1, 2, 3);
269 }
270 };
271
272 let result = analyzer.analyze(&code, "").unwrap();
273 assert!(!result.issues.is_empty());
274 }
275
276 #[test]
277 fn test_detect_write_macro() {
278 let analyzer = FormatArgsAnalyzer::new();
279 let code: File = parse_quote! {
280 fn main() {
281 use std::io::Write;
282 let mut buf = Vec::new();
283 write!(&mut buf, "Values: {} {} {}", 1, 2, 3).unwrap();
284 }
285 };
286
287 let result = analyzer.analyze(&code, "").unwrap();
288 assert!(!result.issues.is_empty());
289 }
290
291 #[test]
292 fn test_detect_writeln_macro() {
293 let analyzer = FormatArgsAnalyzer::new();
294 let code: File = parse_quote! {
295 fn main() {
296 use std::io::Write;
297 let mut buf = Vec::new();
298 writeln!(&mut buf, "Values: {} {} {}", 1, 2, 3).unwrap();
299 }
300 };
301
302 let result = analyzer.analyze(&code, "").unwrap();
303 assert!(!result.issues.is_empty());
304 }
305
306 #[test]
307 fn test_no_edits() {
308 let analyzer = FormatArgsAnalyzer::new();
309 let code: File = parse_quote! {
310 fn main() {
311 println!("Hello {} {} {}", 1, 2, 3);
312 }
313 };
314
315 let edits = analyzer.suggestions(&code, "").unwrap();
316 assert!(edits.is_empty());
317 }
318
319 #[test]
320 fn test_default_implementation() {
321 let analyzer = FormatArgsAnalyzer;
322 assert_eq!(analyzer.name(), "format_args");
323 }
324
325 #[test]
326 fn test_format_without_args() {
327 let analyzer = FormatArgsAnalyzer::new();
328 let code: File = parse_quote! {
329 fn main() {
330 println!("Hello world");
331 }
332 };
333
334 let result = analyzer.analyze(&code, "").unwrap();
335 assert_eq!(result.issues.len(), 0);
336 }
337
338 #[test]
339 fn test_count_debug_and_spec_placeholders() {
340 let analyzer = FormatArgsAnalyzer::new();
341 let code: File = parse_quote! {
342 fn main() {
343 println!("{} {} {:?}", a, b, c);
344 }
345 };
346
347 let result = analyzer.analyze(&code, "").unwrap();
348 assert_eq!(result.issues.len(), 1);
349 assert!(result.issues[0].message.contains("3 placeholders"));
350 }
351
352 #[test]
353 fn test_ignore_named_placeholders_over_threshold() {
354 let analyzer = FormatArgsAnalyzer::new();
355 let code: File = parse_quote! {
356 fn main() {
357 println!("{a} {b} {c}", a = 1, b = 2, c = 3);
358 }
359 };
360
361 let result = analyzer.analyze(&code, "").unwrap();
362 assert_eq!(result.issues.len(), 0);
363 }
364
365 #[test]
366 fn test_escaped_braces_not_counted() {
367 let analyzer = FormatArgsAnalyzer::new();
368 let code: File = parse_quote! {
369 fn main() {
370 println!("{{}} {{}} {{}} {}", x);
371 }
372 };
373
374 let result = analyzer.analyze(&code, "").unwrap();
375 assert_eq!(result.issues.len(), 0);
376 }
377
378 #[test]
379 fn test_indexed_placeholders_counted() {
380 let analyzer = FormatArgsAnalyzer::new();
381 let code: File = parse_quote! {
382 fn main() {
383 println!("{0} {1} {2}", a, b, c);
384 }
385 };
386
387 let result = analyzer.analyze(&code, "").unwrap();
388 assert_eq!(result.issues.len(), 1);
389 }
390
391 #[test]
392 fn test_count_positional_placeholders_helper() {
393 assert_eq!(
394 FormatArgsAnalyzer::count_positional_placeholders("{} {} {:?}"),
395 3
396 );
397 assert_eq!(
398 FormatArgsAnalyzer::count_positional_placeholders("{a} {b} {c}"),
399 0
400 );
401 assert_eq!(
402 FormatArgsAnalyzer::count_positional_placeholders("{{}} literal {}"),
403 1
404 );
405 assert_eq!(
406 FormatArgsAnalyzer::count_positional_placeholders("{0} {1:>8} {2:.2}"),
407 3
408 );
409 }
410}