Skip to main content

wowlab_tidy/languages/rust/rules/correctness/
raw_spell_id.rs

1use 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});