Skip to main content

wowlab_engine_domain/rotation/validate/
mod.rs

1//! Pre-compilation validation for rotation definitions.
2
3use wowlab_types::sim::{Condition, FastSet, Rotation, RotationAction as Action, VarOp};
4
5use super::buffer::DescriptorTable;
6
7mod dependency_graph;
8mod errors;
9mod schema;
10#[cfg(test)]
11mod tests;
12
13use dependency_graph::{
14    action_list_dependency_graph, dependency_postorder, expanded_rotation_action_count,
15    variable_dependency_graph,
16};
17pub use errors::{ValidationError, ValidationResult, ValidationWarning};
18pub use schema::{VarPathCategory, VarPathInfo, get_var_path_schema};
19
20pub(super) const MAX_CONDITION_DEPTH: usize = 256;
21
22/// Ceiling on expanded action nodes; bounds compiler work and IR from branching list graphs.
23pub(super) const MAX_EXPANDED_ACTIONS: usize = 65_536;
24
25fn condition_depth(root: &Condition) -> usize {
26    let mut max_depth = 0;
27
28    root.walk_with_depth(&mut |_condition, depth| max_depth = max_depth.max(depth));
29
30    max_depth
31}
32
33struct ValidationCtx<'rotation, 'ctx> {
34    variable_names: &'ctx FastSet<String>,
35    list_names: &'ctx FastSet<String>,
36    table: &'ctx DescriptorTable,
37    used_variables: &'ctx mut FastSet<&'rotation str>,
38    used_lists: &'ctx mut FastSet<String>,
39    errors: &'ctx mut Vec<ValidationError>,
40}
41
42/// Runs all structural checks on a parsed [`Rotation`].
43// #t(fn: rust_clone_in_loop) building owned errors/warnings from borrowed AST data
44#[must_use]
45pub fn validate_rotation(rotation: &Rotation) -> ValidationResult {
46    fn collect_setvar_names(actions: &[Action], names: &mut FastSet<String>) {
47        for action in actions {
48            if let Action::SetVar { name, .. } = action {
49                names.insert(name.clone());
50            }
51        }
52    }
53
54    let expected_findings = rotation.variables.len() + rotation.lists.len();
55    let mut errors = Vec::with_capacity(expected_findings);
56    let mut warnings = Vec::with_capacity(expected_findings);
57
58    let table = DescriptorTable::build();
59
60    let mut variable_names: FastSet<_> = rotation.variables.keys().cloned().collect();
61    let list_names: FastSet<_> = rotation.lists.keys().cloned().collect();
62
63    collect_setvar_names(&rotation.actions, &mut variable_names);
64
65    for actions in rotation.lists.values() {
66        collect_setvar_names(actions, &mut variable_names);
67    }
68
69    let mut used_variables = FastSet::default();
70    let mut used_lists = FastSet::default();
71
72    if rotation.actions.is_empty() {
73        errors.push(ValidationError::EmptyActionList {
74            list_name: "actions".to_string(),
75        });
76    }
77
78    for (name, actions) in &rotation.lists {
79        if actions.is_empty() {
80            errors.push(ValidationError::EmptyActionList {
81                list_name: name.clone(),
82            });
83        }
84    }
85
86    let mut ctx = ValidationCtx {
87        variable_names: &variable_names,
88        list_names: &list_names,
89        table: &table,
90        used_variables: &mut used_variables,
91        used_lists: &mut used_lists,
92        errors: &mut errors,
93    };
94
95    for (idx, action) in rotation.actions.iter().enumerate() {
96        validate_action(&mut ctx, action, "actions", idx);
97    }
98
99    for (list_name, actions) in &rotation.lists {
100        for (idx, action) in actions.iter().enumerate() {
101            validate_action(&mut ctx, action, list_name, idx);
102        }
103    }
104
105    for cond in rotation.variables.values() {
106        validate_condition(&mut ctx, cond);
107    }
108
109    validate_dependency_graphs(rotation, ctx.errors);
110
111    for name in &variable_names {
112        if !used_variables.contains(name.as_str()) {
113            warnings.push(ValidationWarning::UnusedVariable { name: name.clone() });
114        }
115    }
116
117    for name in &list_names {
118        if !used_lists.contains(name) {
119            warnings.push(ValidationWarning::UnusedList { name: name.clone() });
120        }
121    }
122
123    ValidationResult {
124        valid: errors.is_empty(),
125        errors,
126        warnings,
127    }
128}
129
130fn validate_dependency_graphs(rotation: &Rotation, errors: &mut Vec<ValidationError>) {
131    let variable_graph = variable_dependency_graph(&rotation.variables);
132
133    if let Err(path) = dependency_postorder(&variable_graph) {
134        errors.push(ValidationError::CircularReference { path });
135    }
136
137    let list_graph = action_list_dependency_graph(&rotation.lists);
138
139    match dependency_postorder(&list_graph) {
140        Err(path) => errors.push(ValidationError::CircularReference { path }),
141        Ok(postorder) => {
142            let actions = expanded_rotation_action_count(rotation, &postorder);
143
144            if actions > MAX_EXPANDED_ACTIONS {
145                errors.push(ValidationError::ActionExpansionLimitExceeded {
146                    actions,
147                    max: MAX_EXPANDED_ACTIONS,
148                });
149            }
150        }
151    }
152}
153
154fn is_supported_action(action: &Action) -> bool {
155    matches!(
156        action,
157        Action::Cast { .. }
158            | Action::Call { .. }
159            | Action::Run { .. }
160            | Action::SetVar { .. }
161            | Action::ModifyVar { .. }
162            | Action::Wait { .. }
163            | Action::WaitUntil { .. }
164            | Action::Pool { .. }
165            | Action::UseTrinket { .. }
166            | Action::UseItem { .. }
167    )
168}
169
170fn is_supported_var_op(op: VarOp) -> bool {
171    matches!(
172        op,
173        VarOp::Add
174            | VarOp::Sub
175            | VarOp::Mul
176            | VarOp::Div
177            | VarOp::Mod
178            | VarOp::Max
179            | VarOp::Min
180            | VarOp::Floor
181            | VarOp::Ceil
182            | VarOp::Reset
183    )
184}
185
186fn validate_empower_rank(
187    errors: &mut Vec<ValidationError>,
188    action: &Action,
189    list_name: &str,
190    action_index: usize,
191) {
192    let invalid_slug = match action {
193        Action::Cast {
194            spell,
195            empower_rank: Some(0),
196            ..
197        } => Some(spell.clone()),
198        Action::UseItem {
199            name,
200            empower_rank: Some(0),
201            ..
202        } => Some(name.clone()),
203        Action::UseTrinket {
204            slot,
205            empower_rank: Some(0),
206            ..
207        } => Some(format!("trinket{slot}")),
208        _ => None,
209    };
210
211    if let Some(slug) = invalid_slug {
212        errors.push(ValidationError::InvalidExpression {
213            message: "empower_rank must be one-based".to_string(),
214            list_name: Some(list_name.to_string()),
215            action_index: Some(action_index),
216            slug: Some(slug),
217        });
218    }
219}
220
221fn validate_action_list_reference(ctx: &mut ValidationCtx<'_, '_>, action: &Action) {
222    let (Action::Call { list, .. } | Action::Run { list, .. }) = action else {
223        return;
224    };
225
226    if ctx.list_names.contains(list) {
227        ctx.used_lists.insert(list.clone());
228    } else {
229        ctx.errors
230            .push(ValidationError::UndefinedList { name: list.clone() });
231    }
232}
233
234fn validate_modify_var<'rotation>(
235    ctx: &mut ValidationCtx<'rotation, '_>,
236    action: &'rotation Action,
237    list_name: &str,
238    action_index: usize,
239) {
240    let Action::ModifyVar {
241        name, op, value, ..
242    } = action
243    else {
244        return;
245    };
246
247    if !is_supported_var_op(*op) {
248        ctx.errors.push(ValidationError::UnsupportedSyntax {
249            construct: format!("variable operator {op:?}"),
250        });
251
252        return;
253    }
254
255    if ctx.variable_names.contains(name) {
256        ctx.used_variables.insert(name);
257    } else {
258        ctx.errors
259            .push(ValidationError::UndefinedVariable { name: name.clone() });
260    }
261
262    let requires_numeric = matches!(
263        op,
264        VarOp::Add | VarOp::Sub | VarOp::Mul | VarOp::Div | VarOp::Min | VarOp::Max
265    );
266
267    if requires_numeric && matches!(value, Condition::Bool { .. }) {
268        ctx.errors.push(ValidationError::TypeMismatch {
269            name: name.clone(),
270            op: op.name().to_string(),
271            expected: "int or float".to_string(),
272            got: "bool".to_string(),
273            list_name: Some(list_name.to_string()),
274            action_index: Some(action_index),
275        });
276    }
277}
278
279fn validate_action<'rotation>(
280    ctx: &mut ValidationCtx<'rotation, '_>,
281    action: &'rotation Action,
282    list_name: &str,
283    action_index: usize,
284) {
285    if !is_supported_action(action) {
286        ctx.errors.push(ValidationError::UnsupportedSyntax {
287            construct: format!("action variant {action:?}"),
288        });
289
290        return;
291    }
292
293    validate_empower_rank(ctx.errors, action, list_name, action_index);
294    validate_action_list_reference(ctx, action);
295
296    for cond in action.conditions() {
297        validate_condition(ctx, cond);
298    }
299
300    validate_modify_var(ctx, action, list_name, action_index);
301}
302
303fn validate_condition<'rotation>(
304    ctx: &mut ValidationCtx<'rotation, '_>,
305    root: &'rotation Condition,
306) {
307    let depth = condition_depth(root);
308
309    if depth > MAX_CONDITION_DEPTH {
310        ctx.errors.push(ValidationError::MaxDepthExceeded {
311            depth,
312            max: MAX_CONDITION_DEPTH,
313        });
314        // Don't walk the tree further; downstream recursion is exactly what the bound protects.
315
316        return;
317    }
318
319    let mut undefined_variables = Vec::new();
320
321    root.walk(&mut |cond| match cond {
322        Condition::Var { name } => {
323            if ctx.variable_names.contains(name) {
324                ctx.used_variables.insert(name);
325            } else {
326                undefined_variables.push(name.as_str());
327            }
328        }
329        Condition::Read { field } if ctx.table.lookup(&field.domain, &field.name).is_none() => {
330            ctx.errors.push(ValidationError::UnknownField {
331                domain: field.domain.clone(),
332                name: field.name.clone(),
333            });
334        }
335        Condition::Compare { op, .. } => match op {
336            wowlab_types::sim::CompareOp::Gt
337            | wowlab_types::sim::CompareOp::Gte
338            | wowlab_types::sim::CompareOp::Lt
339            | wowlab_types::sim::CompareOp::Lte
340            | wowlab_types::sim::CompareOp::Eq
341            | wowlab_types::sim::CompareOp::Ne => {}
342            _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
343                construct: format!("comparison operator {op:?}"),
344            }),
345        },
346        Condition::Arith { op, .. } => match op {
347            wowlab_types::sim::ArithOp::Add
348            | wowlab_types::sim::ArithOp::Sub
349            | wowlab_types::sim::ArithOp::Mul
350            | wowlab_types::sim::ArithOp::Div
351            | wowlab_types::sim::ArithOp::Mod => {}
352            _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
353                construct: format!("arithmetic operator {op:?}"),
354            }),
355        },
356        Condition::UnaryMath { op, .. } => match op {
357            wowlab_types::sim::UnaryMathOp::Floor
358            | wowlab_types::sim::UnaryMathOp::Ceil
359            | wowlab_types::sim::UnaryMathOp::Abs => {}
360            _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
361                construct: format!("unary math operator {op:?}"),
362            }),
363        },
364        Condition::MinMax { op, .. } => match op {
365            wowlab_types::sim::MinMaxOp::Min | wowlab_types::sim::MinMaxOp::Max => {}
366            _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
367                construct: format!("min/max operator {op:?}"),
368            }),
369        },
370        Condition::Read { .. }
371        | Condition::Bool { .. }
372        | Condition::Int { .. }
373        | Condition::Float { .. }
374        | Condition::And { .. }
375        | Condition::Or { .. }
376        | Condition::Not { .. }
377        | Condition::IfThenElse { .. } => {}
378        _ => ctx.errors.push(ValidationError::UnsupportedSyntax {
379            construct: format!("condition variant {cond:?}"),
380        }),
381    });
382
383    ctx.errors
384        .extend(
385            undefined_variables
386                .into_iter()
387                .map(|name| ValidationError::UndefinedVariable {
388                    name: name.to_owned(),
389                }),
390        );
391}