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 | "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 if entry.param_count() == 0 && entry.required_param_count() == 0 {
340 return;
341 }
342 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 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 (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 (
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}