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                    | "min"
234                    | "max"
235                    | "raiseToPower"
236                    | "appendToArray"
237                    | "removeFromArray"
238                    | "removeFromArrayByIndex"
239            ) && (args.is_empty()
240                || matches!(name.as_str(), "memberAccess" | "+" | "-" | "*" | "/" | "%"));
241            let known = canonical_helper
242                || catalog.entry(Kind::Value, name).is_some()
243                || catalog.entry(Kind::Operator, name).is_some();
244            if !known {
245                errors.push(WorkshopError::Unknown {
246                    kind: "value",
247                    spelling: name.clone(),
248                    locale: crate::catalog::Locale::new("en-US"),
249                    span: node.span,
250                });
251            } else if name == "memberAccess" {
252                if !(2..=3).contains(&args.len()) {
253                    errors.push(WorkshopError::Malformed {
254                        message: "memberAccess expects two or three arguments".to_string(),
255                        span: node.span,
256                    });
257                } else if !matches!(
258                    program.values.get(args[1]),
259                    Some(wir::ValueNode {
260                        value: wir::Value::String(_),
261                        ..
262                    })
263                ) {
264                    errors.push(WorkshopError::Malformed {
265                        message: "memberAccess member must be a string".to_string(),
266                        span: node.span,
267                    });
268                }
269            } else if !canonical_helper {
270                if let Some(entry) = catalog.entry(Kind::Value, name) {
271                    validate_call_signature(entry, args, node.span, program, catalog, errors);
272                }
273            }
274            for arg in args {
275                validate_value(program, catalog, *arg, errors);
276            }
277        }
278        wir::Value::Enum {
279            value_type, value, ..
280        } => {
281            if catalog.enum_domain(value_type).is_none() {
282                errors.push(WorkshopError::Unknown {
283                    kind: "enum domain",
284                    spelling: value_type.clone(),
285                    locale: crate::catalog::Locale::new("en-US"),
286                    span: node.span,
287                });
288            } else if catalog
289                .enum_spelling(value_type, &crate::catalog::Locale::new("en-US"), value)
290                .is_none()
291            {
292                errors.push(WorkshopError::Unknown {
293                    kind: "enum member",
294                    spelling: value.clone(),
295                    locale: crate::catalog::Locale::new("en-US"),
296                    span: node.span,
297                });
298            }
299        }
300        wir::Value::Array(elements) => {
301            for element in elements {
302                validate_value(program, catalog, *element, errors);
303            }
304        }
305        wir::Value::Vector { x, y, z } => {
306            validate_value(program, catalog, *x, errors);
307            validate_value(program, catalog, *y, errors);
308            validate_value(program, catalog, *z, errors);
309        }
310        wir::Value::PlayerVariable { player, .. } => {
311            validate_value(program, catalog, *player, errors);
312        }
313        wir::Value::Subroutine(subroutine) => {
314            if !program.subroutines.contains(*subroutine) {
315                errors.push(WorkshopError::Malformed {
316                    message: format!("dangling subroutine value {}", subroutine.index()),
317                    span: node.span,
318                });
319            }
320        }
321        wir::Value::Number { .. }
322        | wir::Value::String(_)
323        | wir::Value::LocalizedString(_)
324        | wir::Value::Bool(_)
325        | wir::Value::Null
326        | wir::Value::GlobalVariable(_)
327        | wir::Value::EventPlayer => {}
328    }
329}
330
331fn validate_call_signature(
332    entry: &crate::catalog::CatalogEntry,
333    args: &[wir::ValueId],
334    span: Option<crate::source::Span>,
335    program: &wir::Program,
336    catalog: &Catalog,
337    errors: &mut Vec<WorkshopError>,
338) {
339    // An empty signature in the current inventory means that arity is not
340    // declared, not that the builtin is a zero-argument function. This is
341    // important for documented variadic calls such as Custom String.
342    if entry.param_count() == 0 && entry.required_param_count() == 0 {
343        return;
344    }
345    // Trailing defaults may make a signature partial, but every supplied
346    // argument is still checked against its declared position.
347    if (args.is_empty() && entry.required_param_count() > 0)
348        || (!entry.variadic && args.len() > entry.param_count())
349    {
350        errors.push(WorkshopError::Unsupported {
351            message: format!(
352                "{} '{}' expects {}..{}{} argument(s), got {}",
353                entry.kind.as_str(),
354                entry.id,
355                entry.required_param_count(),
356                entry.param_count(),
357                if entry.variadic { "+" } else { "" },
358                args.len()
359            ),
360            span,
361        });
362        return;
363    }
364
365    for (index, arg_id) in args.iter().enumerate() {
366        if entry.id == "string"
367            && index == 0
368            && !matches!(
369                program.values.get(*arg_id).map(|node| &node.value),
370                Some(wir::Value::LocalizedString(_))
371            )
372        {
373            errors.push(WorkshopError::Unsupported {
374                message: "value 'string' argument 1 must be localized string text".to_string(),
375                span: program.values.get(*arg_id).and_then(|node| node.span),
376            });
377            continue;
378        }
379        if let Some(expected) = entry.param_type(index) {
380            if !value_matches_type(program, catalog, *arg_id, expected) {
381                let actual = value_type_name(program, catalog, *arg_id);
382                errors.push(WorkshopError::Unsupported {
383                    message: format!(
384                        "{} '{}' argument {} must have semantic type '{}', got {}",
385                        entry.kind.as_str(),
386                        entry.id,
387                        index + 1,
388                        expected,
389                        actual
390                    ),
391                    span: program.values.get(*arg_id).and_then(|node| node.span),
392                });
393            }
394        }
395        let Some(domain) = entry.param_domain(index) else {
396            continue;
397        };
398        let Some(node) = program.values.get(*arg_id) else {
399            continue;
400        };
401        // A declared enum domain constrains enum literals. Dynamic values,
402        // Null, and defaults remain valid expressions for the same position;
403        // their runtime value cannot be proven from WIR alone.
404        let valid = match &node.value {
405            wir::Value::Enum {
406                value_type, value, ..
407            } => {
408                value_type == domain
409                    && catalog
410                        .enum_spelling(domain, catalog.primary_locale(), value)
411                        .is_some()
412            }
413            _ => true,
414        };
415        if !valid {
416            let actual = match &node.value {
417                wir::Value::Enum {
418                    value_type, value, ..
419                } => {
420                    format!("{value_type}.{value}")
421                }
422                _ => "non-enum expression".to_string(),
423            };
424            errors.push(WorkshopError::Unsupported {
425                message: format!(
426                    "{} '{}' argument {} must be a member of enum domain '{}', got {}",
427                    entry.kind.as_str(),
428                    entry.id,
429                    index + 1,
430                    domain,
431                    actual
432                ),
433                span: node.span,
434            });
435        }
436    }
437}
438
439fn value_matches_type(
440    program: &wir::Program,
441    catalog: &Catalog,
442    value_id: wir::ValueId,
443    expected: &str,
444) -> bool {
445    let Some(node) = program.values.get(value_id) else {
446        return false;
447    };
448    expected
449        .split('|')
450        .any(|alternative| value_matches_single_type(catalog, &node.value, alternative))
451}
452
453fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &str) -> bool {
454    match (value, expected) {
455        (_, "Any" | "Unknown") => true,
456        (wir::Value::Number { .. }, "Number") => true,
457        (wir::Value::String(_) | wir::Value::LocalizedString(_), "String" | "Text") => true,
458        (wir::Value::Bool(_), "Boolean") => true,
459        (wir::Value::Vector { .. }, "Vector") => true,
460        (wir::Value::Array(_), "Array") => true,
461        (
462            wir::Value::Number { .. }
463            | wir::Value::String(_)
464            | wir::Value::LocalizedString(_)
465            | wir::Value::Bool(_)
466            | wir::Value::Vector { .. },
467            "Object",
468        ) => true,
469        (wir::Value::Enum { value_type, .. }, domain) => {
470            matches!(domain, "Any" | "Unknown" | "Object") || value_type == domain
471        }
472        (wir::Value::Call { name, .. }, expected) => {
473            if expected == "Operation"
474                && matches!(
475                    name.as_str(),
476                    "add"
477                        | "subtract"
478                        | "multiply"
479                        | "divide"
480                        | "modulo"
481                        | "min"
482                        | "max"
483                        | "raiseToPower"
484                        | "appendToArray"
485                        | "removeFromArray"
486                        | "removeFromArrayByIndex"
487                )
488            {
489                return true;
490            }
491            catalog
492                .entry(crate::catalog::Kind::Value, name)
493                .and_then(|entry| entry.return_type())
494                .is_none_or(|return_type| {
495                    return_type
496                        .split('|')
497                        .any(|actual| semantic_types_compatible(actual, expected))
498                })
499        }
500        // Null is a valid Workshop placeholder for every value contract;
501        // its runtime meaning is resolved by the enclosing builtin.
502        (wir::Value::Null, _) => true,
503        (wir::Value::GlobalVariable(_), "Global Variable") => true,
504        (wir::Value::PlayerVariable { .. }, "Player Variable") => true,
505        (wir::Value::Subroutine(_), "Subroutine") => true,
506        (wir::Value::EventPlayer, "Player") => true,
507        // Variables and other runtime expressions are intentionally accepted
508        // for value contracts whose runtime contents are not statically
509        // knowable, but their statically known reference category must not be
510        // coerced into another variable/reference category.
511        (
512            wir::Value::GlobalVariable(_)
513            | wir::Value::PlayerVariable { .. }
514            | wir::Value::Subroutine(_)
515            | wir::Value::EventPlayer,
516            expected,
517        ) => !matches!(
518            expected,
519            "Global Variable" | "Player Variable" | "Subroutine"
520        ),
521        _ => false,
522    }
523}
524
525fn semantic_types_compatible(actual: &str, expected: &str) -> bool {
526    matches!(actual, "Any" | "Unknown")
527        || matches!(expected, "Any" | "Unknown")
528        || actual == expected
529        || actual == "Object"
530        || (expected == "Object" && actual != "Array" && actual != "Void")
531        || (actual == "Object" && expected == "Object")
532}
533
534fn value_type_name(program: &wir::Program, catalog: &Catalog, value_id: wir::ValueId) -> String {
535    let Some(node) = program.values.get(value_id) else {
536        return "missing".to_string();
537    };
538    match &node.value {
539        wir::Value::Number { .. } => "Number".to_string(),
540        wir::Value::String(_) | wir::Value::LocalizedString(_) => "String".to_string(),
541        wir::Value::Bool(_) => "Boolean".to_string(),
542        wir::Value::Vector { .. } => "Vector".to_string(),
543        wir::Value::Array(_) => "Array".to_string(),
544        wir::Value::Enum { value_type, .. } => value_type.clone(),
545        wir::Value::Call { name, .. } => catalog
546            .entry(crate::catalog::Kind::Value, name)
547            .and_then(|entry| entry.return_type())
548            .unwrap_or("dynamic")
549            .to_string(),
550        wir::Value::Null => "Null".to_string(),
551        wir::Value::GlobalVariable(_) | wir::Value::PlayerVariable { .. } => "Variable".to_string(),
552        wir::Value::Subroutine(_) => "Subroutine".to_string(),
553        wir::Value::EventPlayer => "Player".to_string(),
554    }
555}