Skip to main content

cargo_quality/analyzers/
format_args.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use masterror::AppResult;
5use proc_macro2::TokenTree;
6use syn::{ExprMacro, File, LitStr, Macro, spanned::Spanned};
7
8use crate::analyzer::{AnalysisResult, Analyzer, Fix, Issue};
9
10/// Analyzer for format macro arguments
11pub 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    /// Extract the format string literal from macro tokens.
42    ///
43    /// Returns the unescaped value of the first top-level string literal, which
44    /// is the format string for both `print`/`format` and `write`/`writeln`
45    /// families (the writer argument is not a string literal).
46    ///
47    /// # Arguments
48    ///
49    /// * `mac` - Macro invocation to inspect
50    ///
51    /// # Returns
52    ///
53    /// `Some(String)` with the format string value, or `None` if absent
54    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    /// Count positional placeholders in a format string.
67    ///
68    /// Counts `{}`, indexed `{0}`, and spec-only `{:?}` placeholders. Named
69    /// placeholders such as `{name}` or `{name:spec}` are ignored, and
70    /// `{{`/`}}` are treated as escapes.
71    ///
72    /// # Arguments
73    ///
74    /// * `format` - Unescaped format string value
75    ///
76    /// # Returns
77    ///
78    /// Number of positional placeholders
79    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    fn fix(&self, _ast: &mut File) -> AppResult<usize> {
144        Ok(0)
145    }
146}
147
148struct FormatVisitor {
149    issues: Vec<Issue>
150}
151
152impl<'ast> syn::visit::Visit<'ast> for FormatVisitor {
153    fn visit_expr_macro(&mut self, node: &'ast ExprMacro) {
154        self.check_macro(&node.mac);
155        syn::visit::visit_expr_macro(self, node);
156    }
157
158    fn visit_stmt_macro(&mut self, node: &'ast syn::StmtMacro) {
159        self.check_macro(&node.mac);
160        syn::visit::visit_stmt_macro(self, node);
161    }
162}
163
164impl FormatVisitor {
165    fn check_macro(&mut self, mac: &Macro) {
166        let path = &mac.path;
167
168        if (path.is_ident("format")
169            || path.is_ident("println")
170            || path.is_ident("print")
171            || path.is_ident("write")
172            || path.is_ident("writeln"))
173            && let Some(issue) = FormatArgsAnalyzer::analyze_format_macro(mac)
174        {
175            self.issues.push(issue);
176        }
177    }
178}
179
180impl Default for FormatArgsAnalyzer {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use syn::parse_quote;
189
190    use super::*;
191
192    #[test]
193    fn test_analyzer_name() {
194        let analyzer = FormatArgsAnalyzer::new();
195        assert_eq!(analyzer.name(), "format_args");
196    }
197
198    #[test]
199    fn test_detect_positional_args() {
200        let analyzer = FormatArgsAnalyzer::new();
201        let code: File = parse_quote! {
202            fn main() {
203                println!("Values: {} {} {}", 1, 2, 3);
204            }
205        };
206
207        let result = analyzer.analyze(&code, "").unwrap();
208        assert!(!result.issues.is_empty());
209    }
210
211    #[test]
212    fn test_advisory_only_not_fixable() {
213        let analyzer = FormatArgsAnalyzer::new();
214        let code: File = parse_quote! {
215            fn main() {
216                println!("Values: {} {} {}", 1, 2, 3);
217            }
218        };
219
220        let result = analyzer.analyze(&code, "").unwrap();
221        assert!(!result.issues.is_empty());
222        assert_eq!(result.fixable_count, 0);
223        assert!(!result.issues[0].fix.is_available());
224    }
225
226    #[test]
227    fn test_ignore_simple_positional() {
228        let analyzer = FormatArgsAnalyzer::new();
229        let code: File = parse_quote! {
230            fn main() {
231                println!("Value: {}", 42);
232                println!("Two: {} {}", 1, 2);
233            }
234        };
235
236        let result = analyzer.analyze(&code, "").unwrap();
237        assert_eq!(result.issues.len(), 0);
238    }
239
240    #[test]
241    fn test_ignore_named_args() {
242        let analyzer = FormatArgsAnalyzer::new();
243        let code: File = parse_quote! {
244            fn main() {
245                let name = "World";
246                println!("Hello {name}");
247            }
248        };
249
250        let result = analyzer.analyze(&code, "").unwrap();
251        assert_eq!(result.issues.len(), 0);
252    }
253
254    #[test]
255    fn test_detect_format_macro() {
256        let analyzer = FormatArgsAnalyzer::new();
257        let code: File = parse_quote! {
258            fn main() {
259                let msg = format!("Values: {} {} {}", 1, 2, 3);
260            }
261        };
262
263        let result = analyzer.analyze(&code, "").unwrap();
264        assert!(!result.issues.is_empty());
265    }
266
267    #[test]
268    fn test_detect_print_macro() {
269        let analyzer = FormatArgsAnalyzer::new();
270        let code: File = parse_quote! {
271            fn main() {
272                print!("Values: {} {} {}", 1, 2, 3);
273            }
274        };
275
276        let result = analyzer.analyze(&code, "").unwrap();
277        assert!(!result.issues.is_empty());
278    }
279
280    #[test]
281    fn test_detect_write_macro() {
282        let analyzer = FormatArgsAnalyzer::new();
283        let code: File = parse_quote! {
284            fn main() {
285                use std::io::Write;
286                let mut buf = Vec::new();
287                write!(&mut buf, "Values: {} {} {}", 1, 2, 3).unwrap();
288            }
289        };
290
291        let result = analyzer.analyze(&code, "").unwrap();
292        assert!(!result.issues.is_empty());
293    }
294
295    #[test]
296    fn test_detect_writeln_macro() {
297        let analyzer = FormatArgsAnalyzer::new();
298        let code: File = parse_quote! {
299            fn main() {
300                use std::io::Write;
301                let mut buf = Vec::new();
302                writeln!(&mut buf, "Values: {} {} {}", 1, 2, 3).unwrap();
303            }
304        };
305
306        let result = analyzer.analyze(&code, "").unwrap();
307        assert!(!result.issues.is_empty());
308    }
309
310    #[test]
311    fn test_fix_returns_zero() {
312        let analyzer = FormatArgsAnalyzer::new();
313        let mut code: File = parse_quote! {
314            fn main() {
315                println!("Hello {} {} {}", 1, 2, 3);
316            }
317        };
318
319        let fixed = analyzer.fix(&mut code).unwrap();
320        assert_eq!(fixed, 0);
321    }
322
323    #[test]
324    fn test_default_implementation() {
325        let analyzer = FormatArgsAnalyzer;
326        assert_eq!(analyzer.name(), "format_args");
327    }
328
329    #[test]
330    fn test_format_without_args() {
331        let analyzer = FormatArgsAnalyzer::new();
332        let code: File = parse_quote! {
333            fn main() {
334                println!("Hello world");
335            }
336        };
337
338        let result = analyzer.analyze(&code, "").unwrap();
339        assert_eq!(result.issues.len(), 0);
340    }
341
342    #[test]
343    fn test_count_debug_and_spec_placeholders() {
344        let analyzer = FormatArgsAnalyzer::new();
345        let code: File = parse_quote! {
346            fn main() {
347                println!("{} {} {:?}", a, b, c);
348            }
349        };
350
351        let result = analyzer.analyze(&code, "").unwrap();
352        assert_eq!(result.issues.len(), 1);
353        assert!(result.issues[0].message.contains("3 placeholders"));
354    }
355
356    #[test]
357    fn test_ignore_named_placeholders_over_threshold() {
358        let analyzer = FormatArgsAnalyzer::new();
359        let code: File = parse_quote! {
360            fn main() {
361                println!("{a} {b} {c}", a = 1, b = 2, c = 3);
362            }
363        };
364
365        let result = analyzer.analyze(&code, "").unwrap();
366        assert_eq!(result.issues.len(), 0);
367    }
368
369    #[test]
370    fn test_escaped_braces_not_counted() {
371        let analyzer = FormatArgsAnalyzer::new();
372        let code: File = parse_quote! {
373            fn main() {
374                println!("{{}} {{}} {{}} {}", x);
375            }
376        };
377
378        let result = analyzer.analyze(&code, "").unwrap();
379        assert_eq!(result.issues.len(), 0);
380    }
381
382    #[test]
383    fn test_indexed_placeholders_counted() {
384        let analyzer = FormatArgsAnalyzer::new();
385        let code: File = parse_quote! {
386            fn main() {
387                println!("{0} {1} {2}", a, b, c);
388            }
389        };
390
391        let result = analyzer.analyze(&code, "").unwrap();
392        assert_eq!(result.issues.len(), 1);
393    }
394
395    #[test]
396    fn test_count_positional_placeholders_helper() {
397        assert_eq!(
398            FormatArgsAnalyzer::count_positional_placeholders("{} {} {:?}"),
399            3
400        );
401        assert_eq!(
402            FormatArgsAnalyzer::count_positional_placeholders("{a} {b} {c}"),
403            0
404        );
405        assert_eq!(
406            FormatArgsAnalyzer::count_positional_placeholders("{{}} literal {}"),
407            1
408        );
409        assert_eq!(
410            FormatArgsAnalyzer::count_positional_placeholders("{0} {1:>8} {2:.2}"),
411            3
412        );
413    }
414}