wowlab_tidy/languages/rust/rules/correctness/
raw_spell_id.rs1use ra_ap_syntax::{
2 AstNode, AstToken,
3 ast::{self, LiteralKind},
4};
5
6use crate::{AstCtx, Example, Violation};
7
8#[rustfmt::skip]
9const GENERATED_MODULES: &[&str] = &[
10 "SPELL",
11 "AURA",
12 "TALENT",
13 "HERO",
14 "EFFECT",
15 "REPORTED_SPELL",
16 "SET_BONUS",
17];
18const MAX_NON_SPELL_ID: u128 = 999;
19
20#[rustfmt::skip]
21const EXAMPLES: &[Example] = &[
22 Example {
23 label: "raw spell ID in expression",
24 code: "fn cast() { trigger_spell(123_456); }",
25 pass: false,
26 },
27 Example {
28 label: "small integer in expression",
29 code: "fn stacks() -> u32 { 999 }",
30 pass: true,
31 },
32 Example {
33 label: "named tuning constant may contain a large integer",
34 code: "const MS_PER_CP_CTTC: u32 = 3000;",
35 pass: true,
36 },
37 Example {
38 label: "generated spell constant used directly",
39 code: "fn cast() { trigger_spell(SPELL::MORTAL_STRIKE); }",
40 pass: true,
41 },
42 Example {
43 label: "generated spell constant re-alias",
44 code: "const MORTAL_STRIKE: u32 = SPELL::MORTAL_STRIKE;",
45 pass: false,
46 },
47 Example {
48 label: "generated aura static re-alias",
49 code: "static TEST_AURA: u32 = generated::warrior::AURA::TEST;",
50 pass: false,
51 },
52 Example {
53 label: "generated reported spell used directly",
54 code: "fn report() { record(REPORTED_SPELL::TEST); }",
55 pass: true,
56 },
57 Example {
58 label: "generated reported spell re-alias",
59 code: "const TEST_DAMAGE: u32 = REPORTED_SPELL::TEST;",
60 pass: false,
61 },
62 Example {
63 label: "derived generated constant initializer",
64 code: "const WINDOW: u32 = EFFECT::WINDOW * 2;",
65 pass: true,
66 },
67 Example {
68 label: "raw spell ID in macro expression",
69 code: "fn cast() { trigger!(123_456); }",
70 pass: false,
71 },
72 Example {
73 label: "macro tuning value in named constant",
74 code: "const WINDOW: u32 = value!(3000);",
75 pass: true,
76 },
77];
78
79crate::ast_rule!(
80 raw_spell_id,
81 "Ban raw spell IDs and generated-constant re-aliases in spec hooks.",
82 "Hook code should name game-data bindings at each use site so IDs remain traceable to generated data and constant roles do not drift.",
83 High,
84);
85
86fn check_raw_spell_id(ctx: &AstCtx<'_>) -> Vec<Violation> {
87 if !is_hooks_source(ctx.file.rel) && !cfg!(test) {
88 return Vec::new();
89 }
90
91 let literals = ctx
92 .nodes::<ast::Literal>()
93 .filter(|literal| !ctx.is_in_test(literal) && !inside_binding(literal))
94 .filter_map(|literal| {
95 let LiteralKind::IntNumber(integer) = literal.kind() else {
96 return None;
97 };
98
99 raw_integer_violation(ctx, &integer, &literal)
100 });
101 let const_bindings = ctx
102 .nodes::<ast::Const>()
103 .filter(|item| !ctx.is_in_test(item))
104 .filter_map(|item| binding_initializer(item.syntax()));
105 let const_aliases = const_bindings
106 .filter_map(|initializer| generated_constant_path(&initializer))
107 .map(|path| {
108 ctx.violation(
109 &path,
110 "generated constant re-alias — use the generated binding directly",
111 )
112 });
113 let static_bindings = ctx
114 .nodes::<ast::Static>()
115 .filter(|item| !ctx.is_in_test(item))
116 .filter_map(|item| binding_initializer(item.syntax()));
117 let static_aliases = static_bindings
118 .filter_map(|initializer| generated_constant_path(&initializer))
119 .map(|path| {
120 ctx.violation(
121 &path,
122 "generated constant re-alias — use the generated binding directly",
123 )
124 });
125 let macros = ctx
126 .nodes::<ast::MacroCall>()
127 .filter(|call| !ctx.is_in_test(call) && !inside_binding(call))
128 .flat_map(|call| macro_integer_violations(ctx, &call));
129
130 literals
131 .chain(const_aliases)
132 .chain(static_aliases)
133 .chain(macros)
134 .collect()
135}
136
137fn is_hooks_source(rel: &str) -> bool {
138 rel.starts_with("crates/engine-content/src/hooks/")
139}
140
141fn generated_constant_path(expr: &ast::Expr) -> Option<ast::Path> {
142 let ast::Expr::PathExpr(expr_path) = expr else {
143 return None;
144 };
145 let path = expr_path.path()?;
146
147 path.syntax()
148 .descendants()
149 .filter_map(ast::NameRef::cast)
150 .any(|name| GENERATED_MODULES.contains(&name.text().as_str()))
151 .then_some(path)
152}
153
154fn binding_initializer(node: &ra_ap_syntax::SyntaxNode) -> Option<ast::Expr> {
155 node.children().find_map(ast::Expr::cast)
156}
157
158fn inside_binding<N>(node: &N) -> bool
159where
160 N: AstNode,
161{
162 node.syntax().ancestors().skip(1).any(|ancestor| {
163 ast::Const::can_cast(ancestor.kind()) || ast::Static::can_cast(ancestor.kind())
164 })
165}
166
167fn raw_integer_violation<N>(
168 ctx: &AstCtx<'_>,
169 integer: &ast::IntNumber,
170 location: &N,
171) -> Option<Violation>
172where
173 N: AstNode,
174{
175 integer
176 .value()
177 .ok()
178 .filter(|value| *value > MAX_NON_SPELL_ID)
179 .map(|value| {
180 ctx.violation(
181 location,
182 format!("raw spell ID `{value}` in hook expression — use the generated binding"),
183 )
184 })
185}
186
187fn macro_integer_violations(ctx: &AstCtx<'_>, call: &ast::MacroCall) -> Vec<Violation> {
188 call.token_tree()
189 .into_iter()
190 .flat_map(|tree| tree.syntax().descendants_with_tokens())
191 .filter_map(ra_ap_syntax::NodeOrToken::into_token)
192 .filter_map(ast::IntNumber::cast)
193 .filter_map(|integer| {
194 integer
195 .value()
196 .ok()
197 .filter(|value| *value > MAX_NON_SPELL_ID)
198 .map(|value| {
199 let line = ctx
200 .line_index
201 .line_col(integer.syntax().text_range().start())
202 .line as usize
203 + 1;
204
205 crate::violation(
206 ctx.file.rel,
207 line,
208 format!(
209 "raw spell ID `{value}` in hook expression — use the generated binding"
210 ),
211 )
212 })
213 })
214 .collect()
215}
216
217crate::tidy_ast_test!(check_raw_spell_id, {
218 crate::example_tests!(EXAMPLES, check_raw_spell_id);
219});