wowlab_tidy/languages/rust/rules/hygiene/
log_named_events.rs1use crate::{
2 Example, FileCtx, Violation,
3 infra::{parse, scanner},
4 violation,
5};
6
7#[rustfmt::skip]
8const EXAMPLES: &[Example] = &[
9 Example {
10 label: "unnamed event",
11 code: "event!(Level::INFO, foo = 1, \"msg\");",
12 pass: false,
13 },
14 Example {
15 label: "unnamed multi-line event",
16 code: "event!(\n Level::INFO,\n file.path = p,\n);",
17 pass: false,
18 },
19 Example {
20 label: "name after level",
21 code: "event!(Level::INFO, name: \"a.b.c\", \"msg\");",
22 pass: false,
23 },
24 Example {
25 label: "named event",
26 code: "event!(name: \"file.open.success\", Level::INFO, \"msg\");",
27 pass: true,
28 },
29 Example {
30 label: "named multi-line event",
31 code: "tracing::event!(\n name: \"a.b.c\",\n Level::INFO,\n);",
32 pass: true,
33 },
34 Example {
35 label: "event in comment",
36 code: "// event!(Level::INFO, \"msg\");",
37 pass: true,
38 },
39 Example {
40 label: "event in string literal",
41 code: "let s = \"event!(Level::INFO)\";",
42 pass: true,
43 },
44 Example {
45 label: "other macro named event",
46 code: "custom_event!(Level::INFO, \"msg\");",
47 pass: true,
48 },
49 Example {
50 label: "non-event log macro",
51 code: "info!(\"msg\");",
52 pass: true,
53 },
54];
55
56crate::line_rule!(
57 log_named_events,
58 "Flag `event!(...)` invocations without a `name:` argument before the level.",
59 "Unnamed events cannot be grouped or filtered across log entries; name them `<component>.<operation>.<state>`.",
60 Low,
61);
62
63const EVENT_OPEN: &str = "event!(";
64const MAX_SCAN_LINES: usize = 30;
65
66fn is_ident_char(ch: char) -> bool {
67 ch.is_ascii_alphanumeric() || ch == '_'
68}
69
70fn event_invocation_start(code: &str) -> Option<usize> {
71 let mut from = 0;
72
73 while let Some(pos) = code.get(from..).and_then(|rest| rest.find(EVENT_OPEN)) {
74 let abs = from + pos;
75 let prev = code.get(..abs).and_then(|before| before.chars().last());
76
77 if !prev.is_some_and(is_ident_char) {
78 return Some(abs + EVENT_OPEN.len());
79 }
80
81 from = abs + EVENT_OPEN.len();
82 }
83
84 None
85}
86
87fn consume(text: &str, depth: &mut usize, content: &mut String) -> bool {
88 for ch in text.chars() {
89 match ch {
90 '(' => *depth += 1,
91 ')' => {
92 *depth -= 1;
93
94 if *depth == 0 {
95 return true;
96 }
97 }
98 _ => {}
99 }
100
101 if !ch.is_whitespace() {
102 content.push(ch);
103 }
104 }
105
106 false
107}
108
109fn has_name_before_level(content: &str) -> bool {
110 let Some(name_pos) = content.find("name:") else {
111 return false;
112 };
113
114 content
115 .find("Level::")
116 .is_none_or(|level_pos| name_pos < level_pos)
117}
118
119fn check_log_named_events(ctx: &FileCtx<'_>) -> Vec<Violation> {
120 let mut out = Vec::new();
121
122 for (i, line) in ctx.lines.iter().enumerate() {
123 if parse::is_comment(line.trim()) {
124 continue;
125 }
126
127 let code = scanner::code_only(line);
128 let Some(start) = event_invocation_start(&code) else {
129 continue;
130 };
131
132 let mut content = String::new();
133 let mut depth: usize = 1;
134 let mut done = consume(code.get(start..).unwrap_or(""), &mut depth, &mut content);
135 let mut next = i + 1;
136
137 while !done && next - i < MAX_SCAN_LINES {
138 let Some(next_line) = ctx.lines.get(next) else {
139 break;
140 };
141
142 done = consume(&scanner::code_only(next_line), &mut depth, &mut content);
143 next += 1;
144 }
145
146 if !has_name_before_level(&content) {
147 out.push(violation(
148 ctx.rel,
149 i + 1,
150 "event!() without a leading name: argument — name events `<component>.<operation>.<state>`",
151 ));
152 }
153 }
154
155 out
156}
157
158crate::tidy_test!(check_log_named_events, {
159 crate::example_tests!(EXAMPLES, check_log_named_events);
160});