Skip to main content

workshop_rs/
validate.rs

1//! Catalog-backed validation of Workshop-origin WIR.
2//!
3//! WIR builtin references are locale-independent canonical catalog ids. This
4//! module validates a Workshop IR program against the canonical catalog so an
5//! unknown, misspelled, or locale-tainted builtin is rejected deterministically
6//! instead of being stored as opaque unchecked text.
7
8use crate::wir;
9
10use crate::catalog::{Catalog, Kind};
11use crate::error::{Result, WorkshopError};
12
13/// Validate every builtin reference in a Workshop-origin WIR program against
14/// the catalog: action/value call names must be known canonical ids, and enum
15/// references must resolve to a canonical member of a known domain.
16pub fn validate_canonical_ids(program: &wir::Program, catalog: &Catalog) -> Result<()> {
17    let mut errors = Vec::new();
18    for (index, _) in program.rules.iter().enumerate() {
19        let rule = wir::RuleId::from_index(index);
20        let Some(rule_data) = program.rules.get(rule) else {
21            continue;
22        };
23        validate_event(&rule_data.event, rule_data.span, catalog, &mut errors);
24        for action in &rule_data.actions {
25            validate_action(program, catalog, *action, &mut errors);
26        }
27        for condition in &rule_data.conditions {
28            validate_value(program, catalog, *condition, &mut errors);
29        }
30    }
31    errors.into_iter().next().map_or(Ok(()), Err)
32}
33
34fn validate_event(
35    event: &wir::Event,
36    span: Option<crate::source::Span>,
37    catalog: &Catalog,
38    errors: &mut Vec<WorkshopError>,
39) {
40    let (id, filters) = match event {
41        wir::Event::Global => ("global", None),
42        wir::Event::EachPlayer => ("eachPlayer", None),
43        wir::Event::EachPlayerWithFilters { team, target } => ("eachPlayer", Some((*team, target))),
44        wir::Event::Player { kind, team, target } => (kind.catalog_id(), Some((*team, target))),
45        wir::Event::Subroutine(_) => ("subroutine", None),
46    };
47    if catalog.entry(Kind::Event, id).is_none() {
48        errors.push(WorkshopError::Unknown {
49            kind: "event",
50            spelling: id.to_string(),
51            locale: crate::catalog::Locale::new("en-US"),
52            span,
53        });
54        return;
55    }
56    let Some((team, target)) = filters else {
57        return;
58    };
59    let en = crate::catalog::Locale::new("en-US");
60    let team_member = match team {
61        wir::EventTeam::All => "ALL",
62        wir::EventTeam::Team1 => "TEAM_1",
63        wir::EventTeam::Team2 => "TEAM_2",
64    };
65    if catalog
66        .enum_spelling("EventTeam", &en, team_member)
67        .is_none()
68    {
69        errors.push(WorkshopError::Unknown {
70            kind: "event team",
71            spelling: team_member.to_string(),
72            locale: en.clone(),
73            span,
74        });
75    }
76    let target_member = match target {
77        wir::EventTarget::All => Some("ALL".to_string()),
78        wir::EventTarget::Slot(slot) => Some(format!("SLOT_{slot}")),
79        wir::EventTarget::Hero(hero) => {
80            if catalog.enum_spelling("Hero", &en, hero).is_none() {
81                errors.push(WorkshopError::Unknown {
82                    kind: "event player",
83                    spelling: hero.clone(),
84                    locale: en.clone(),
85                    span,
86                });
87            }
88            None
89        }
90    };
91    if let Some(target_member) = target_member {
92        if catalog
93            .enum_spelling("EventPlayer", &en, &target_member)
94            .is_none()
95        {
96            errors.push(WorkshopError::Unknown {
97                kind: "event player",
98                spelling: target_member,
99                locale: en,
100                span,
101            });
102        }
103    }
104}
105
106fn validate_action(
107    program: &wir::Program,
108    catalog: &Catalog,
109    action_id: wir::ActionId,
110    errors: &mut Vec<WorkshopError>,
111) {
112    let Some(action) = program.actions.get(action_id) else {
113        return;
114    };
115    match action {
116        wir::Action::Call { name, args, span } => {
117            let entry = catalog.entry(Kind::Action, name);
118            if entry.is_none() {
119                errors.push(WorkshopError::Unknown {
120                    kind: "action",
121                    spelling: name.clone(),
122                    locale: crate::catalog::Locale::new("en-US"),
123                    span: *span,
124                });
125            } else if let Some(entry) = entry {
126                validate_call_signature(entry, args, *span, program, catalog, errors);
127            }
128            for arg in args {
129                validate_value(program, catalog, *arg, errors);
130            }
131        }
132        wir::Action::SetGlobalVariable { value, .. }
133        | wir::Action::ModifyGlobalVariable { value, .. }
134        | wir::Action::Debug { value, .. }
135        | wir::Action::Print { message: value, .. } => {
136            validate_value(program, catalog, *value, errors);
137        }
138        wir::Action::SetPlayerVariable { player, value, .. }
139        | wir::Action::ModifyPlayerVariable { player, value, .. } => {
140            validate_value(program, catalog, *player, errors);
141            validate_value(program, catalog, *value, errors);
142        }
143        wir::Action::AssignMember { target, value, .. } => {
144            validate_value(program, catalog, *target, errors);
145            validate_value(program, catalog, *value, errors);
146        }
147        wir::Action::If {
148            branches,
149            else_body,
150            ..
151        } => {
152            for branch in branches {
153                validate_value(program, catalog, branch.condition, errors);
154                for action in &branch.body {
155                    validate_action(program, catalog, *action, errors);
156                }
157            }
158            if let Some(else_body) = else_body {
159                for action in else_body {
160                    validate_action(program, catalog, *action, errors);
161                }
162            }
163        }
164        wir::Action::While {
165            condition, body, ..
166        } => {
167            validate_value(program, catalog, *condition, errors);
168            for action in body {
169                validate_action(program, catalog, *action, errors);
170            }
171        }
172        wir::Action::ForGlobalVariable {
173            start,
174            stop,
175            step,
176            body,
177            ..
178        } => {
179            validate_value(program, catalog, *start, errors);
180            validate_value(program, catalog, *stop, errors);
181            validate_value(program, catalog, *step, errors);
182            for action in body {
183                validate_action(program, catalog, *action, errors);
184            }
185        }
186        wir::Action::ForPlayerVariable {
187            player,
188            start,
189            stop,
190            step,
191            body,
192            ..
193        } => {
194            validate_value(program, catalog, *player, errors);
195            validate_value(program, catalog, *start, errors);
196            validate_value(program, catalog, *stop, errors);
197            validate_value(program, catalog, *step, errors);
198            for action in body {
199                validate_action(program, catalog, *action, errors);
200            }
201        }
202        wir::Action::CallSubroutine { .. } => {}
203    }
204}
205
206fn validate_value(
207    program: &wir::Program,
208    catalog: &Catalog,
209    value_id: wir::ValueId,
210    errors: &mut Vec<WorkshopError>,
211) {
212    let Some(node) = program.values.get(value_id) else {
213        return;
214    };
215    match &node.value {
216        wir::Value::Call { name, args } => {
217            // Comparison operators are represented as call names (`==`, `<`,
218            // …) following the `Compare(a, op, b)` convention, so both value
219            // and operator identities are valid call names.
220            let canonical_helper = matches!(
221                name.as_str(),
222                "memberAccess"
223                    | "+"
224                    | "-"
225                    | "*"
226                    | "/"
227                    | "%"
228                    | "add"
229                    | "subtract"
230                    | "multiply"
231                    | "divide"
232                    | "modulo"
233                    | "raiseToPower"
234                    | "appendToArray"
235                    | "removeFromArray"
236                    | "removeFromArrayByIndex"
237            ) && (args.is_empty()
238                || matches!(name.as_str(), "memberAccess" | "+" | "-" | "*" | "/" | "%"));
239            let known = canonical_helper
240                || catalog.entry(Kind::Value, name).is_some()
241                || catalog.entry(Kind::Operator, name).is_some();
242            if !known {
243                errors.push(WorkshopError::Unknown {
244                    kind: "value",
245                    spelling: name.clone(),
246                    locale: crate::catalog::Locale::new("en-US"),
247                    span: node.span,
248                });
249            } else if name == "memberAccess" {
250                if !(2..=3).contains(&args.len()) {
251                    errors.push(WorkshopError::Malformed {
252                        message: "memberAccess expects two or three arguments".to_string(),
253                        span: node.span,
254                    });
255                } else if !matches!(
256                    program.values.get(args[1]),
257                    Some(wir::ValueNode {
258                        value: wir::Value::String(_),
259                        ..
260                    })
261                ) {
262                    errors.push(WorkshopError::Malformed {
263                        message: "memberAccess member must be a string".to_string(),
264                        span: node.span,
265                    });
266                }
267            } else if !canonical_helper {
268                if let Some(entry) = catalog.entry(Kind::Value, name) {
269                    validate_call_signature(entry, args, node.span, program, catalog, errors);
270                }
271            }
272            for arg in args {
273                validate_value(program, catalog, *arg, errors);
274            }
275        }
276        wir::Value::Enum {
277            value_type, value, ..
278        } => {
279            if catalog.enum_domain(value_type).is_none() {
280                errors.push(WorkshopError::Unknown {
281                    kind: "enum domain",
282                    spelling: value_type.clone(),
283                    locale: crate::catalog::Locale::new("en-US"),
284                    span: node.span,
285                });
286            } else if catalog
287                .enum_spelling(value_type, &crate::catalog::Locale::new("en-US"), value)
288                .is_none()
289            {
290                errors.push(WorkshopError::Unknown {
291                    kind: "enum member",
292                    spelling: value.clone(),
293                    locale: crate::catalog::Locale::new("en-US"),
294                    span: node.span,
295                });
296            }
297        }
298        wir::Value::Array(elements) => {
299            for element in elements {
300                validate_value(program, catalog, *element, errors);
301            }
302        }
303        wir::Value::Vector { x, y, z } => {
304            validate_value(program, catalog, *x, errors);
305            validate_value(program, catalog, *y, errors);
306            validate_value(program, catalog, *z, errors);
307        }
308        wir::Value::PlayerVariable { player, .. } => {
309            validate_value(program, catalog, *player, errors);
310        }
311        wir::Value::Subroutine(subroutine) => {
312            if !program.subroutines.contains(*subroutine) {
313                errors.push(WorkshopError::Malformed {
314                    message: format!("dangling subroutine value {}", subroutine.index()),
315                    span: node.span,
316                });
317            }
318        }
319        wir::Value::Number { .. }
320        | wir::Value::String(_)
321        | wir::Value::Bool(_)
322        | wir::Value::Null
323        | wir::Value::GlobalVariable(_)
324        | wir::Value::EventPlayer => {}
325    }
326}
327
328fn validate_call_signature(
329    entry: &crate::catalog::CatalogEntry,
330    args: &[wir::ValueId],
331    span: Option<crate::source::Span>,
332    program: &wir::Program,
333    catalog: &Catalog,
334    errors: &mut Vec<WorkshopError>,
335) {
336    // An empty signature in the current inventory means that arity is not
337    // declared, not that the builtin is a zero-argument function. This is
338    // important for documented variadic calls such as Custom String.
339    if entry.param_count() == 0 && entry.required_param_count() == 0 {
340        return;
341    }
342    // Trailing defaults may make a signature partial, but every supplied
343    // argument is still checked against its declared position.
344    if (args.is_empty() && entry.required_param_count() > 0)
345        || (!entry.variadic && args.len() > entry.param_count())
346    {
347        errors.push(WorkshopError::Unsupported {
348            message: format!(
349                "{} '{}' expects {}..{}{} argument(s), got {}",
350                entry.kind.as_str(),
351                entry.id,
352                entry.required_param_count(),
353                entry.param_count(),
354                if entry.variadic { "+" } else { "" },
355                args.len()
356            ),
357            span,
358        });
359        return;
360    }
361
362    for (index, arg_id) in args.iter().enumerate() {
363        if let Some(expected) = entry.param_type(index) {
364            if !value_matches_type(program, catalog, *arg_id, expected) {
365                let actual = value_type_name(program, catalog, *arg_id);
366                errors.push(WorkshopError::Unsupported {
367                    message: format!(
368                        "{} '{}' argument {} must have semantic type '{}', got {}",
369                        entry.kind.as_str(),
370                        entry.id,
371                        index + 1,
372                        expected,
373                        actual
374                    ),
375                    span: program.values.get(*arg_id).and_then(|node| node.span),
376                });
377            }
378        }
379        let Some(domain) = entry.param_domain(index) else {
380            continue;
381        };
382        let Some(node) = program.values.get(*arg_id) else {
383            continue;
384        };
385        // A declared enum domain constrains enum literals. Dynamic values,
386        // Null, and defaults remain valid expressions for the same position;
387        // their runtime value cannot be proven from WIR alone.
388        let valid = match &node.value {
389            wir::Value::Enum {
390                value_type, value, ..
391            } => {
392                value_type == domain
393                    && catalog
394                        .enum_spelling(domain, catalog.primary_locale(), value)
395                        .is_some()
396            }
397            _ => true,
398        };
399        if !valid {
400            let actual = match &node.value {
401                wir::Value::Enum {
402                    value_type, value, ..
403                } => {
404                    format!("{value_type}.{value}")
405                }
406                _ => "non-enum expression".to_string(),
407            };
408            errors.push(WorkshopError::Unsupported {
409                message: format!(
410                    "{} '{}' argument {} must be a member of enum domain '{}', got {}",
411                    entry.kind.as_str(),
412                    entry.id,
413                    index + 1,
414                    domain,
415                    actual
416                ),
417                span: node.span,
418            });
419        }
420    }
421}
422
423fn value_matches_type(
424    program: &wir::Program,
425    catalog: &Catalog,
426    value_id: wir::ValueId,
427    expected: &str,
428) -> bool {
429    let Some(node) = program.values.get(value_id) else {
430        return false;
431    };
432    expected
433        .split('|')
434        .any(|alternative| value_matches_single_type(catalog, &node.value, alternative))
435}
436
437fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &str) -> bool {
438    match (value, expected) {
439        (_, "Any" | "Unknown") => true,
440        (wir::Value::Number { .. }, "Number") => true,
441        (wir::Value::String(_), "String" | "Text") => true,
442        (wir::Value::Bool(_), "Boolean") => true,
443        (wir::Value::Vector { .. }, "Vector") => true,
444        (wir::Value::Array(_), "Array") => true,
445        (
446            wir::Value::Number { .. }
447            | wir::Value::String(_)
448            | wir::Value::Bool(_)
449            | wir::Value::Vector { .. },
450            "Object",
451        ) => true,
452        (wir::Value::Enum { value_type, .. }, domain) => {
453            matches!(domain, "Any" | "Unknown" | "Object") || value_type == domain
454        }
455        (wir::Value::Call { name, .. }, expected) => {
456            if expected == "Operation"
457                && matches!(
458                    name.as_str(),
459                    "add"
460                        | "subtract"
461                        | "multiply"
462                        | "divide"
463                        | "modulo"
464                        | "raiseToPower"
465                        | "appendToArray"
466                        | "removeFromArray"
467                        | "removeFromArrayByIndex"
468                )
469            {
470                return true;
471            }
472            catalog
473                .entry(crate::catalog::Kind::Value, name)
474                .and_then(|entry| entry.return_type())
475                .is_none_or(|return_type| {
476                    return_type
477                        .split('|')
478                        .any(|actual| semantic_types_compatible(actual, expected))
479                })
480        }
481        // Null is a valid Workshop placeholder for every value contract;
482        // its runtime meaning is resolved by the enclosing builtin.
483        (wir::Value::Null, _) => true,
484        (wir::Value::GlobalVariable(_), "Global Variable") => true,
485        (wir::Value::PlayerVariable { .. }, "Player Variable") => true,
486        (wir::Value::Subroutine(_), "Subroutine") => true,
487        (wir::Value::EventPlayer, "Player") => true,
488        // Variables and other runtime expressions are intentionally accepted
489        // for value contracts whose runtime contents are not statically
490        // knowable, but their statically known reference category must not be
491        // coerced into another variable/reference category.
492        (
493            wir::Value::GlobalVariable(_)
494            | wir::Value::PlayerVariable { .. }
495            | wir::Value::Subroutine(_)
496            | wir::Value::EventPlayer,
497            expected,
498        ) => !matches!(
499            expected,
500            "Global Variable" | "Player Variable" | "Subroutine"
501        ),
502        _ => false,
503    }
504}
505
506fn semantic_types_compatible(actual: &str, expected: &str) -> bool {
507    matches!(actual, "Any" | "Unknown")
508        || matches!(expected, "Any" | "Unknown")
509        || actual == expected
510        || actual == "Object"
511        || (expected == "Object" && actual != "Array" && actual != "Void")
512        || (actual == "Object" && expected == "Object")
513}
514
515fn value_type_name(program: &wir::Program, catalog: &Catalog, value_id: wir::ValueId) -> String {
516    let Some(node) = program.values.get(value_id) else {
517        return "missing".to_string();
518    };
519    match &node.value {
520        wir::Value::Number { .. } => "Number".to_string(),
521        wir::Value::String(_) => "String".to_string(),
522        wir::Value::Bool(_) => "Boolean".to_string(),
523        wir::Value::Vector { .. } => "Vector".to_string(),
524        wir::Value::Array(_) => "Array".to_string(),
525        wir::Value::Enum { value_type, .. } => value_type.clone(),
526        wir::Value::Call { name, .. } => catalog
527            .entry(crate::catalog::Kind::Value, name)
528            .and_then(|entry| entry.return_type())
529            .unwrap_or("dynamic")
530            .to_string(),
531        wir::Value::Null => "Null".to_string(),
532        wir::Value::GlobalVariable(_) | wir::Value::PlayerVariable { .. } => "Variable".to_string(),
533        wir::Value::Subroutine(_) => "Subroutine".to_string(),
534        wir::Value::EventPlayer => "Player".to_string(),
535    }
536}