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            validate_value(program, catalog, *value, errors);
135        }
136        wir::Action::SetPlayerVariable { player, value, .. }
137        | wir::Action::ModifyPlayerVariable { player, value, .. } => {
138            validate_value(program, catalog, *player, errors);
139            validate_value(program, catalog, *value, errors);
140        }
141        wir::Action::AssignMember {
142            target,
143            value,
144            span,
145            ..
146        } => {
147            if !is_member_assignment_target(program, *target) {
148                errors.push(WorkshopError::Malformed {
149                    message: "AssignMember target must be a memberAccess value".to_string(),
150                    span: *span,
151                });
152            }
153            validate_value(program, catalog, *target, errors);
154            validate_value(program, catalog, *value, errors);
155        }
156        wir::Action::If {
157            branches,
158            else_body,
159            ..
160        } => {
161            for branch in branches {
162                validate_value(program, catalog, branch.condition, errors);
163                for action in &branch.body {
164                    validate_action(program, catalog, *action, errors);
165                }
166            }
167            if let Some(else_body) = else_body {
168                for action in else_body {
169                    validate_action(program, catalog, *action, errors);
170                }
171            }
172        }
173        wir::Action::While {
174            condition, body, ..
175        } => {
176            validate_value(program, catalog, *condition, errors);
177            for action in body {
178                validate_action(program, catalog, *action, errors);
179            }
180        }
181        wir::Action::ForGlobalVariable {
182            start,
183            stop,
184            step,
185            body,
186            ..
187        } => {
188            validate_value(program, catalog, *start, errors);
189            validate_value(program, catalog, *stop, errors);
190            validate_value(program, catalog, *step, errors);
191            for action in body {
192                validate_action(program, catalog, *action, errors);
193            }
194        }
195        wir::Action::ForPlayerVariable {
196            player,
197            start,
198            stop,
199            step,
200            body,
201            ..
202        } => {
203            validate_value(program, catalog, *player, errors);
204            validate_value(program, catalog, *start, errors);
205            validate_value(program, catalog, *stop, errors);
206            validate_value(program, catalog, *step, errors);
207            for action in body {
208                validate_action(program, catalog, *action, errors);
209            }
210        }
211        wir::Action::CallSubroutine { .. } => {}
212    }
213}
214
215fn is_member_assignment_target(program: &wir::Program, target: wir::ValueId) -> bool {
216    match program.values.get(target) {
217        Some(wir::ValueNode {
218            value: wir::Value::Call { name, args },
219            ..
220        }) if name == "memberAccess" => (2..=3).contains(&args.len()),
221        _ => false,
222    }
223}
224
225fn validate_value(
226    program: &wir::Program,
227    catalog: &Catalog,
228    value_id: wir::ValueId,
229    errors: &mut Vec<WorkshopError>,
230) {
231    let Some(node) = program.values.get(value_id) else {
232        return;
233    };
234    match &node.value {
235        wir::Value::Call { name, args } => {
236            // Comparison operators are represented as call names (`==`, `<`,
237            // …) following the `Compare(a, op, b)` convention, so both value
238            // and operator identities are valid call names.
239            let canonical_helper = matches!(
240                name.as_str(),
241                "memberAccess"
242                    | "+"
243                    | "-"
244                    | "*"
245                    | "/"
246                    | "%"
247                    | "add"
248                    | "subtract"
249                    | "multiply"
250                    | "divide"
251                    | "modulo"
252                    | "min"
253                    | "max"
254                    | "raiseToPower"
255                    | "appendToArray"
256                    | "removeFromArray"
257                    | "removeFromArrayByIndex"
258            ) && (args.is_empty()
259                || matches!(name.as_str(), "memberAccess" | "+" | "-" | "*" | "/" | "%"));
260            let known = canonical_helper
261                || catalog.entry(Kind::Value, name).is_some()
262                || catalog.entry(Kind::Operator, name).is_some();
263            if !known {
264                errors.push(WorkshopError::Unknown {
265                    kind: "value",
266                    spelling: name.clone(),
267                    locale: crate::catalog::Locale::new("en-US"),
268                    span: node.span,
269                });
270            } else if name == "memberAccess" {
271                if !(2..=3).contains(&args.len()) {
272                    errors.push(WorkshopError::Malformed {
273                        message: "memberAccess expects two or three arguments".to_string(),
274                        span: node.span,
275                    });
276                } else if !matches!(
277                    program.values.get(args[1]),
278                    Some(wir::ValueNode {
279                        value: wir::Value::String(_),
280                        ..
281                    })
282                ) {
283                    errors.push(WorkshopError::Malformed {
284                        message: "memberAccess member must be a string".to_string(),
285                        span: node.span,
286                    });
287                }
288            } else if !canonical_helper {
289                if let Some(entry) = catalog.entry(Kind::Value, name) {
290                    validate_call_signature(entry, args, node.span, program, catalog, errors);
291                }
292            }
293            for arg in args {
294                validate_value(program, catalog, *arg, errors);
295            }
296        }
297        wir::Value::Enum {
298            value_type, value, ..
299        } => {
300            if catalog.enum_domain(value_type).is_none() {
301                errors.push(WorkshopError::Unknown {
302                    kind: "enum domain",
303                    spelling: value_type.clone(),
304                    locale: crate::catalog::Locale::new("en-US"),
305                    span: node.span,
306                });
307            } else if catalog
308                .enum_spelling(value_type, &crate::catalog::Locale::new("en-US"), value)
309                .is_none()
310            {
311                errors.push(WorkshopError::Unknown {
312                    kind: "enum member",
313                    spelling: value.clone(),
314                    locale: crate::catalog::Locale::new("en-US"),
315                    span: node.span,
316                });
317            }
318        }
319        wir::Value::Array(elements) => {
320            for element in elements {
321                validate_value(program, catalog, *element, errors);
322            }
323        }
324        wir::Value::Vector { x, y, z } => {
325            validate_value(program, catalog, *x, errors);
326            validate_value(program, catalog, *y, errors);
327            validate_value(program, catalog, *z, errors);
328        }
329        wir::Value::PlayerVariable { player, .. } => {
330            validate_value(program, catalog, *player, errors);
331        }
332        wir::Value::Subroutine(subroutine) => {
333            if !program.subroutines.contains(*subroutine) {
334                errors.push(WorkshopError::Malformed {
335                    message: format!("dangling subroutine value {}", subroutine.index()),
336                    span: node.span,
337                });
338            }
339        }
340        wir::Value::Number { .. }
341        | wir::Value::String(_)
342        | wir::Value::LocalizedString(_)
343        | wir::Value::Bool(_)
344        | wir::Value::Null
345        | wir::Value::GlobalVariable(_)
346        | wir::Value::EventPlayer => {}
347    }
348}
349
350fn validate_call_signature(
351    entry: &crate::catalog::CatalogEntry,
352    args: &[wir::ValueId],
353    span: Option<crate::source::Span>,
354    program: &wir::Program,
355    catalog: &Catalog,
356    errors: &mut Vec<WorkshopError>,
357) {
358    // An empty signature in the current inventory means that arity is not
359    // declared, not that the builtin is a zero-argument function. This is
360    // important for documented variadic calls such as Custom String.
361    if entry.param_count() == 0 && entry.required_param_count() == 0 {
362        return;
363    }
364    // Trailing defaults may make a signature partial, but every supplied
365    // argument is still checked against its declared position.
366    if (args.is_empty() && entry.required_param_count() > 0)
367        || (!entry.variadic && args.len() > entry.param_count())
368    {
369        errors.push(WorkshopError::Unsupported {
370            message: format!(
371                "{} '{}' expects {}..{}{} argument(s), got {}",
372                entry.kind.as_str(),
373                entry.id,
374                entry.required_param_count(),
375                entry.param_count(),
376                if entry.variadic { "+" } else { "" },
377                args.len()
378            ),
379            span,
380        });
381        return;
382    }
383
384    for (index, arg_id) in args.iter().enumerate() {
385        if entry.id == "string"
386            && index == 0
387            && !matches!(
388                program.values.get(*arg_id).map(|node| &node.value),
389                Some(wir::Value::LocalizedString(_))
390            )
391        {
392            errors.push(WorkshopError::Unsupported {
393                message: "value 'string' argument 1 must be localized string text".to_string(),
394                span: program.values.get(*arg_id).and_then(|node| node.span),
395            });
396            continue;
397        }
398        if let Some(expected) = entry.param_type(index) {
399            if !value_matches_type(program, catalog, *arg_id, expected) {
400                let actual = value_type_name(program, catalog, *arg_id);
401                errors.push(WorkshopError::Unsupported {
402                    message: format!(
403                        "{} '{}' argument {} must have semantic type '{}', got {}",
404                        entry.kind.as_str(),
405                        entry.id,
406                        index + 1,
407                        expected,
408                        actual
409                    ),
410                    span: program.values.get(*arg_id).and_then(|node| node.span),
411                });
412            }
413        }
414        let Some(domain) = entry.param_domain(index) else {
415            continue;
416        };
417        let Some(node) = program.values.get(*arg_id) else {
418            continue;
419        };
420        // A declared enum domain constrains enum literals. Dynamic values,
421        // Null, and defaults remain valid expressions for the same position;
422        // their runtime value cannot be proven from WIR alone.
423        let valid = match &node.value {
424            wir::Value::Enum {
425                value_type, value, ..
426            } => {
427                value_type == domain
428                    && catalog
429                        .enum_spelling(domain, catalog.primary_locale(), value)
430                        .is_some()
431            }
432            _ => true,
433        };
434        if !valid {
435            let actual = match &node.value {
436                wir::Value::Enum {
437                    value_type, value, ..
438                } => {
439                    format!("{value_type}.{value}")
440                }
441                _ => "non-enum expression".to_string(),
442            };
443            errors.push(WorkshopError::Unsupported {
444                message: format!(
445                    "{} '{}' argument {} must be a member of enum domain '{}', got {}",
446                    entry.kind.as_str(),
447                    entry.id,
448                    index + 1,
449                    domain,
450                    actual
451                ),
452                span: node.span,
453            });
454        }
455    }
456}
457
458fn value_matches_type(
459    program: &wir::Program,
460    catalog: &Catalog,
461    value_id: wir::ValueId,
462    expected: &str,
463) -> bool {
464    let Some(node) = program.values.get(value_id) else {
465        return false;
466    };
467    expected
468        .split('|')
469        .any(|alternative| value_matches_single_type(catalog, &node.value, alternative))
470}
471
472fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &str) -> bool {
473    match (value, expected) {
474        (_, "Any" | "Unknown") => true,
475        (wir::Value::Number { .. }, "Number") => true,
476        (wir::Value::String(_) | wir::Value::LocalizedString(_), "String" | "Text") => true,
477        (wir::Value::Bool(_), "Boolean") => true,
478        (wir::Value::Vector { .. }, "Vector") => true,
479        (wir::Value::Array(_), "Array") => true,
480        (
481            wir::Value::Number { .. }
482            | wir::Value::String(_)
483            | wir::Value::LocalizedString(_)
484            | wir::Value::Bool(_)
485            | wir::Value::Vector { .. },
486            "Object",
487        ) => true,
488        (wir::Value::Enum { value_type, .. }, domain) => {
489            matches!(domain, "Any" | "Unknown" | "Object") || value_type == domain
490        }
491        (wir::Value::Call { name, .. }, expected) => {
492            if expected == "Operation"
493                && matches!(
494                    name.as_str(),
495                    "add"
496                        | "subtract"
497                        | "multiply"
498                        | "divide"
499                        | "modulo"
500                        | "min"
501                        | "max"
502                        | "raiseToPower"
503                        | "appendToArray"
504                        | "removeFromArray"
505                        | "removeFromArrayByIndex"
506                )
507            {
508                return true;
509            }
510            catalog
511                .entry(crate::catalog::Kind::Value, name)
512                .and_then(|entry| entry.return_type())
513                .is_none_or(|return_type| {
514                    return_type
515                        .split('|')
516                        .any(|actual| semantic_types_compatible(actual, expected))
517                })
518        }
519        // Null is a valid Workshop placeholder for every value contract;
520        // its runtime meaning is resolved by the enclosing builtin.
521        (wir::Value::Null, _) => true,
522        (wir::Value::GlobalVariable(_), "Global Variable") => true,
523        (wir::Value::PlayerVariable { .. }, "Player Variable") => true,
524        (wir::Value::Subroutine(_), "Subroutine") => true,
525        (wir::Value::EventPlayer, "Player") => true,
526        // Variables and other runtime expressions are intentionally accepted
527        // for value contracts whose runtime contents are not statically
528        // knowable, but their statically known reference category must not be
529        // coerced into another variable/reference category.
530        (
531            wir::Value::GlobalVariable(_)
532            | wir::Value::PlayerVariable { .. }
533            | wir::Value::Subroutine(_)
534            | wir::Value::EventPlayer,
535            expected,
536        ) => !matches!(
537            expected,
538            "Global Variable" | "Player Variable" | "Subroutine"
539        ),
540        _ => false,
541    }
542}
543
544fn semantic_types_compatible(actual: &str, expected: &str) -> bool {
545    matches!(actual, "Any" | "Unknown")
546        || matches!(expected, "Any" | "Unknown")
547        || actual == expected
548        || actual == "Object"
549        || (expected == "Object" && actual != "Array" && actual != "Void")
550        || (actual == "Object" && expected == "Object")
551}
552
553fn value_type_name(program: &wir::Program, catalog: &Catalog, value_id: wir::ValueId) -> String {
554    let Some(node) = program.values.get(value_id) else {
555        return "missing".to_string();
556    };
557    match &node.value {
558        wir::Value::Number { .. } => "Number".to_string(),
559        wir::Value::String(_) | wir::Value::LocalizedString(_) => "String".to_string(),
560        wir::Value::Bool(_) => "Boolean".to_string(),
561        wir::Value::Vector { .. } => "Vector".to_string(),
562        wir::Value::Array(_) => "Array".to_string(),
563        wir::Value::Enum { value_type, .. } => value_type.clone(),
564        wir::Value::Call { name, .. } => catalog
565            .entry(crate::catalog::Kind::Value, name)
566            .and_then(|entry| entry.return_type())
567            .unwrap_or("dynamic")
568            .to_string(),
569        wir::Value::Null => "Null".to_string(),
570        wir::Value::GlobalVariable(_) | wir::Value::PlayerVariable { .. } => "Variable".to_string(),
571        wir::Value::Subroutine(_) => "Subroutine".to_string(),
572        wir::Value::EventPlayer => "Player".to_string(),
573    }
574}