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
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}