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 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 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 (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 (
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}