1use crate::wir;
9
10use crate::catalog::{Catalog, Kind};
11use crate::error::{Result, WorkshopError};
12
13pub 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 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 if entry.param_count() == 0 && entry.required_param_count() == 0 {
362 return;
363 }
364 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 && !contextual_value_matches(entry, index, program, *arg_id, expected)
401 {
402 let actual = value_type_name(program, catalog, *arg_id);
403 errors.push(WorkshopError::Unsupported {
404 message: format!(
405 "{} '{}' argument {} must have semantic type '{}', got {}",
406 entry.kind.as_str(),
407 entry.id,
408 index + 1,
409 expected,
410 actual
411 ),
412 span: program.values.get(*arg_id).and_then(|node| node.span),
413 });
414 }
415 }
416 let Some(domain) = entry.param_domain(index) else {
417 continue;
418 };
419 let Some(node) = program.values.get(*arg_id) else {
420 continue;
421 };
422 let valid = match &node.value {
426 wir::Value::Enum {
427 value_type, value, ..
428 } => {
429 value_type == domain
430 && catalog
431 .enum_spelling(domain, catalog.primary_locale(), value)
432 .is_some()
433 }
434 _ => true,
435 };
436 if !valid {
437 let actual = match &node.value {
438 wir::Value::Enum {
439 value_type, value, ..
440 } => {
441 format!("{value_type}.{value}")
442 }
443 _ => "non-enum expression".to_string(),
444 };
445 errors.push(WorkshopError::Unsupported {
446 message: format!(
447 "{} '{}' argument {} must be a member of enum domain '{}', got {}",
448 entry.kind.as_str(),
449 entry.id,
450 index + 1,
451 domain,
452 actual
453 ),
454 span: node.span,
455 });
456 }
457 }
458}
459
460fn contextual_value_matches(
461 entry: &crate::catalog::CatalogEntry,
462 index: usize,
463 program: &wir::Program,
464 value_id: wir::ValueId,
465 expected: &str,
466) -> bool {
467 let Some(coercions) = entry.param_coercions(index) else {
468 return false;
469 };
470 let Some(node) = program.values.get(value_id) else {
471 return false;
472 };
473 let expected_number = expected
474 .split('|')
475 .any(|alternative| matches!(alternative, "Number" | "Any" | "Unknown"));
476 let expected_string = expected
477 .split('|')
478 .any(|alternative| matches!(alternative, "String" | "Text"));
479 match &node.value {
480 wir::Value::Bool(false) => coercions.false_as_number && expected_number,
481 wir::Value::Bool(true) => coercions.true_as_number && expected_number,
482 wir::Value::Number { value, .. } => coercions.zero_as_null && *value == 0.0,
483 wir::Value::Vector { x, y, z } => {
484 coercions.null_vector_as_null
485 && is_zero_number(program, *x)
486 && is_zero_number(program, *y)
487 && is_zero_number(program, *z)
488 }
489 wir::Value::Call { name, args } => {
490 (coercions.null_vector_as_null
491 && name == "vector"
492 && args.len() == 3
493 && args
494 .iter()
495 .all(|value_id| is_zero_number(program, *value_id)))
496 || (coercions.empty_array_as_string
497 && expected_string
498 && name == "emptyArray"
499 && args.is_empty())
500 }
501 wir::Value::Array(elements) => {
502 coercions.empty_array_as_string && expected_string && elements.is_empty()
503 }
504 _ => false,
505 }
506}
507
508fn is_zero_number(program: &wir::Program, value_id: wir::ValueId) -> bool {
509 matches!(
510 program.values.get(value_id),
511 Some(wir::ValueNode {
512 value: wir::Value::Number { value, .. },
513 ..
514 }) if *value == 0.0
515 )
516}
517
518fn value_matches_type(
519 program: &wir::Program,
520 catalog: &Catalog,
521 value_id: wir::ValueId,
522 expected: &str,
523) -> bool {
524 let Some(node) = program.values.get(value_id) else {
525 return false;
526 };
527 expected
528 .split('|')
529 .any(|alternative| value_matches_single_type(catalog, &node.value, alternative))
530}
531
532fn value_matches_single_type(catalog: &Catalog, value: &wir::Value, expected: &str) -> bool {
533 match (value, expected) {
534 (_, "Any" | "Unknown") => true,
535 (wir::Value::Number { .. }, "Number") => true,
536 (wir::Value::String(_) | wir::Value::LocalizedString(_), "String" | "Text") => true,
537 (wir::Value::Bool(_), "Boolean") => true,
538 (wir::Value::Vector { .. }, "Vector") => true,
539 (wir::Value::Array(_), "Array") => true,
540 (
541 wir::Value::Number { .. }
542 | wir::Value::String(_)
543 | wir::Value::LocalizedString(_)
544 | wir::Value::Bool(_)
545 | wir::Value::Vector { .. },
546 "Object",
547 ) => true,
548 (wir::Value::Enum { value_type, .. }, domain) => {
549 matches!(domain, "Any" | "Unknown" | "Object") || value_type == domain
550 }
551 (wir::Value::Call { name, .. }, expected) => {
552 if expected == "Operation"
553 && matches!(
554 name.as_str(),
555 "add"
556 | "subtract"
557 | "multiply"
558 | "divide"
559 | "modulo"
560 | "min"
561 | "max"
562 | "raiseToPower"
563 | "appendToArray"
564 | "removeFromArray"
565 | "removeFromArrayByIndex"
566 )
567 {
568 return true;
569 }
570 catalog
571 .entry(crate::catalog::Kind::Value, name)
572 .and_then(|entry| entry.return_type())
573 .is_none_or(|return_type| {
574 return_type
575 .split('|')
576 .any(|actual| semantic_types_compatible(actual, expected))
577 })
578 }
579 (wir::Value::Null, _) => true,
582 (wir::Value::GlobalVariable(_), "Global Variable") => true,
583 (wir::Value::PlayerVariable { .. }, "Player Variable") => true,
584 (wir::Value::Subroutine(_), "Subroutine") => true,
585 (wir::Value::EventPlayer, "Player") => true,
586 (
591 wir::Value::GlobalVariable(_)
592 | wir::Value::PlayerVariable { .. }
593 | wir::Value::Subroutine(_)
594 | wir::Value::EventPlayer,
595 expected,
596 ) => !matches!(
597 expected,
598 "Global Variable" | "Player Variable" | "Subroutine"
599 ),
600 _ => false,
601 }
602}
603
604fn semantic_types_compatible(actual: &str, expected: &str) -> bool {
605 matches!(actual, "Any" | "Unknown")
606 || matches!(expected, "Any" | "Unknown")
607 || actual == expected
608 || actual == "Object"
609 || (expected == "Object" && actual != "Array" && actual != "Void")
610 || (actual == "Object" && expected == "Object")
611}
612
613fn value_type_name(program: &wir::Program, catalog: &Catalog, value_id: wir::ValueId) -> String {
614 let Some(node) = program.values.get(value_id) else {
615 return "missing".to_string();
616 };
617 match &node.value {
618 wir::Value::Number { .. } => "Number".to_string(),
619 wir::Value::String(_) | wir::Value::LocalizedString(_) => "String".to_string(),
620 wir::Value::Bool(_) => "Boolean".to_string(),
621 wir::Value::Vector { .. } => "Vector".to_string(),
622 wir::Value::Array(_) => "Array".to_string(),
623 wir::Value::Enum { value_type, .. } => value_type.clone(),
624 wir::Value::Call { name, .. } => catalog
625 .entry(crate::catalog::Kind::Value, name)
626 .and_then(|entry| entry.return_type())
627 .unwrap_or("dynamic")
628 .to_string(),
629 wir::Value::Null => "Null".to_string(),
630 wir::Value::GlobalVariable(_) | wir::Value::PlayerVariable { .. } => "Variable".to_string(),
631 wir::Value::Subroutine(_) => "Subroutine".to_string(),
632 wir::Value::EventPlayer => "Player".to_string(),
633 }
634}