wowlab_tidy/languages/rust/rules/hygiene/
format_in_log.rs1use std::collections::VecDeque;
2
3use ra_ap_syntax::{AstNode, SyntaxElement, ast};
4
5use crate::{AstCtx, Example, Violation};
6
7#[rustfmt::skip]
8const EXAMPLES: &[Example] = &[
9 Example {
10 label: "positional placeholder in message",
11 code: "fn f(p: u32) { tracing::info!(\"file opened: {}\", p); }",
12 pass: false,
13 },
14 Example {
15 label: "named inline placeholder in message",
16 code: "fn f(p: u32) { info!(\"opened {p}\"); }",
17 pass: false,
18 },
19 Example {
20 label: "format! argument",
21 code: "fn f(p: u32) { warn!(msg = format!(\"opened\")); }",
22 pass: false,
23 },
24 Example {
25 label: "to_string argument",
26 code: "fn f(p: u32) { log::debug!(value = p.to_string()); }",
27 pass: false,
28 },
29 Example {
30 label: "message template braces pass",
31 code: "fn f(p: u32) { event!(name: \"file.open\", Level::INFO, file.path = p, \"file opened: {{file.path}}\"); }",
32 pass: true,
33 },
34 Example {
35 label: "plain message",
36 code: "fn f() { info!(\"cache flushed\"); }",
37 pass: true,
38 },
39 Example {
40 label: "structured fields",
41 code: "fn f(p: u32) { info!(count = p, \"batch done\"); }",
42 pass: true,
43 },
44 Example {
45 label: "format outside logging",
46 code: "fn f(p: u32) { let s = format!(\"x {}\", p); }",
47 pass: true,
48 },
49 Example {
50 label: "format in log in test module",
51 code: "#[cfg(test)]\nmod tests {\n fn f(p: u32) { info!(\"opened {}\", p); }\n}",
52 pass: true,
53 },
54];
55
56crate::ast_rule!(
57 format_in_log,
58 "Flag runtime string building (`format!`, `.to_string()`, `{}` placeholders) in logging macros.",
59 "String formatting allocates on every emit even when the event is filtered out; structured fields with {{property}} message templates defer formatting to viewing time.",
60 Low,
61);
62
63const LOG_MACROS: &[&str] = &["debug", "error", "event", "info", "log", "trace", "warn"];
64
65fn string_content(lit: &str) -> Option<&str> {
66 let s = lit.strip_prefix('r').unwrap_or(lit);
67 let s = s.trim_start_matches('#');
68 let s = s.strip_prefix('"')?;
69 let s = s.trim_end_matches('#');
70
71 s.strip_suffix('"')
72}
73
74fn has_single_brace_placeholder(content: &str) -> bool {
75 let mut chars = content.chars().peekable();
76
77 while let Some(ch) = chars.next() {
78 match ch {
79 '{' => {
80 if chars.peek() == Some(&'{') {
81 chars.next();
82 } else {
83 return true;
84 }
85 }
86 '}' if chars.peek() == Some(&'}') => {
87 chars.next();
88 }
89 _ => {}
90 }
91 }
92
93 false
94}
95
96fn pair_hit(current: &SyntaxElement, next: &SyntaxElement) -> Option<&'static str> {
97 match (current.as_token(), next.as_token()) {
98 (Some(identifier), Some(punctuation))
99 if identifier.text() == "format" && punctuation.text() == "!" =>
100 {
101 Some(
102 "format!() inside a logging macro — use structured fields instead of runtime string building",
103 )
104 }
105 (Some(punctuation), Some(identifier))
106 if punctuation.text() == "." && identifier.text() == "to_string" =>
107 {
108 Some(
109 ".to_string() inside a logging macro — use structured fields instead of runtime string building",
110 )
111 }
112 _ => None,
113 }
114}
115
116fn literal_hit(text: &str, saw_first_string: &mut bool) -> Option<&'static str> {
117 if *saw_first_string {
118 return None;
119 }
120
121 let content = string_content(text)?;
122
123 *saw_first_string = true;
124
125 has_single_brace_placeholder(content).then_some("format placeholder in log message — use structured fields and {{property}} message templates")
126}
127
128fn scan_log_tokens(call: &ast::MacroCall) -> Option<&'static str> {
129 let mut queue = VecDeque::new();
130
131 queue.push_back(call.token_tree()?);
132 let mut saw_first_string = false;
133
134 while let Some(tree) = queue.pop_front() {
135 let level: Vec<SyntaxElement> = tree
136 .syntax()
137 .children_with_tokens()
138 .filter(|element| {
139 element
140 .as_token()
141 .is_none_or(|token| !token.kind().is_trivia())
142 })
143 .collect();
144
145 for (cur, next) in level.iter().zip(level.iter().skip(1)) {
146 if let Some(message) = pair_hit(cur, next) {
147 return Some(message);
148 }
149 }
150
151 for element in level {
152 match element {
153 SyntaxElement::Token(token) => {
154 if let Some(message) = literal_hit(token.text(), &mut saw_first_string) {
155 return Some(message);
156 }
157 }
158 SyntaxElement::Node(node) => {
159 if let Some(tree) = ast::TokenTree::cast(node) {
160 queue.push_back(tree);
161 }
162 }
163 }
164 }
165 }
166
167 None
168}
169
170fn check_format_in_log(ctx: &AstCtx<'_>) -> Vec<Violation> {
171 ctx.nodes::<ast::MacroCall>()
172 .filter(|call| !ctx.is_in_test(call))
173 .filter_map(|call| {
174 let name = call.path()?.segment()?.name_ref()?.text().to_string();
175
176 if !LOG_MACROS.contains(&name.as_str()) {
177 return None;
178 }
179
180 scan_log_tokens(&call).map(|message| ctx.violation(&call, message))
181 })
182 .collect()
183}
184
185crate::tidy_ast_test!(check_format_in_log, {
186 crate::example_tests!(EXAMPLES, check_format_in_log);
187});