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 | 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 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 if entry.param_count() == 0 && entry.required_param_count() == 0 {
343 return;
344 }
345 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 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 (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 (
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}