Skip to main content

workshop_rs/output/
roundtrip.rs

1//! Cross-language Workshop round-trip compatibility suite.
2//!
3//! [`round_trip`] records the `Workshop(locale) -> WIR -> Workshop(locale) ->
4//! WIR` regression check, and [`equivalent`] compares
5//! two WIR programs structurally, ignoring presentation-only differences
6//! (source spans and file paths) while preserving operations, references,
7//! control flow, and values.
8use crate::wir;
9
10use crate::catalog::{Catalog, Locale};
11use crate::core::signatures::{ExpectedDomain, NoExpectedDomain};
12use crate::frontend::parser;
13use crate::output::emitter;
14
15/// A recorded round-trip result for regression and contract checks.
16#[derive(Debug, Clone, PartialEq)]
17pub struct RoundTripRecord {
18    /// SHA-256 of the input Workshop text.
19    pub input_identity: String,
20    /// The locale the text was parsed and emitted in.
21    pub locale: Locale,
22    /// The catalog schema version used.
23    pub catalog_version: u32,
24    /// Whether the input parsed.
25    pub parse_ok: bool,
26    /// Whether the parsed program emitted.
27    pub emit_ok: bool,
28    /// Whether the emitted text reparsed.
29    pub reparse_ok: bool,
30    /// Whether the original and round-tripped WIR are equivalent.
31    pub equivalent: bool,
32    /// A structured failure message, when any stage failed.
33    pub error: Option<String>,
34}
35
36/// Run `Workshop -> WIR -> Workshop -> WIR` and record the check. The record is
37/// always produced; failures are captured in its `error` field.
38/// Ambiguous bare enum members stay rejected (no signature context).
39pub fn round_trip(input: &str, catalog: &Catalog, locale: &Locale) -> RoundTripRecord {
40    round_trip_with_context(input, catalog, locale, &NoExpectedDomain)
41}
42
43/// The context-sensitive form of [`round_trip`] (#111): reparsing the emitted
44/// text uses the supplied canonical signature context so an ambiguous bare
45/// enum member that the emitter produced (e.g. `Chase Global Variable Over
46/// Time(..., None)`) resolves to the domain the signature pins.
47pub fn round_trip_with_context(
48    input: &str,
49    catalog: &Catalog,
50    locale: &Locale,
51    context: &dyn ExpectedDomain,
52) -> RoundTripRecord {
53    let input_identity = sha256(input);
54    let mut record = RoundTripRecord {
55        input_identity,
56        locale: locale.clone(),
57        catalog_version: catalog.schema_version,
58        parse_ok: false,
59        emit_ok: false,
60        reparse_ok: false,
61        equivalent: false,
62        error: None,
63    };
64    let first = match parser::parse_with_context(input, catalog, locale, context) {
65        Ok(program) => program,
66        Err(error) => {
67            record.error = Some(error.to_string());
68            return record;
69        }
70    };
71    record.parse_ok = true;
72    let emitted = match emitter::emit(&first, catalog, locale) {
73        Ok(text) => text,
74        Err(error) => {
75            record.error = Some(error.to_string());
76            return record;
77        }
78    };
79    record.emit_ok = true;
80    let second = match parser::parse_with_context(&emitted, catalog, locale, context) {
81        Ok(program) => program,
82        Err(error) => {
83            record.error = Some(error.to_string());
84            return record;
85        }
86    };
87    record.reparse_ok = true;
88    record.equivalent = equivalent(&first, &second);
89    record
90}
91
92/// Structural equivalence of two WIR programs: identical settings, tables,
93/// rules, actions, and values, ignoring source spans and file paths.
94pub fn equivalent(a: &wir::Program, b: &wir::Program) -> bool {
95    if !settings_equivalent(a.settings.as_ref(), b.settings.as_ref()) {
96        return false;
97    }
98    let globals_a: Vec<_> = a
99        .global_variables
100        .iter()
101        .map(|v| (v.name.as_str(), v.index))
102        .collect();
103    let globals_b: Vec<_> = b
104        .global_variables
105        .iter()
106        .map(|v| (v.name.as_str(), v.index))
107        .collect();
108    if globals_a != globals_b {
109        return false;
110    }
111    let players_a: Vec<_> = a
112        .player_variables
113        .iter()
114        .map(|v| (v.name.as_str(), v.index))
115        .collect();
116    let players_b: Vec<_> = b
117        .player_variables
118        .iter()
119        .map(|v| (v.name.as_str(), v.index))
120        .collect();
121    if players_a != players_b {
122        return false;
123    }
124    let subs_a: Vec<_> = a
125        .subroutines
126        .iter()
127        .map(|s| (s.name.as_str(), s.index))
128        .collect();
129    let subs_b: Vec<_> = b
130        .subroutines
131        .iter()
132        .map(|s| (s.name.as_str(), s.index))
133        .collect();
134    if subs_a != subs_b {
135        return false;
136    }
137    // Emission intentionally drops pass-only/condition-only rules because
138    // they have no executable behavior. Ignore those presentation-only
139    // source rules when comparing observable semantics.
140    let rules_a: Vec<_> = a
141        .rules
142        .iter()
143        .filter(|rule| !rule.actions.is_empty())
144        .collect();
145    let rules_b: Vec<_> = b
146        .rules
147        .iter()
148        .filter(|rule| !rule.actions.is_empty())
149        .collect();
150    if rules_a.len() != rules_b.len() {
151        return false;
152    }
153    for (rule_a, rule_b) in rules_a.into_iter().zip(rules_b) {
154        if !rule_equivalent(a, b, rule_a, rule_b) {
155            return false;
156        }
157    }
158    true
159}
160
161fn settings_equivalent(
162    left: Option<&crate::settings::Settings>,
163    right: Option<&crate::settings::Settings>,
164) -> bool {
165    match (left, right) {
166        (None, None) => true,
167        (Some(left), Some(right)) => nodes_equivalent(&left.children, &right.children),
168        _ => false,
169    }
170}
171
172fn nodes_equivalent(
173    left: &[crate::settings::SettingsNode],
174    right: &[crate::settings::SettingsNode],
175) -> bool {
176    left.len() == right.len()
177        && left
178            .iter()
179            .zip(right)
180            .all(|(left, right)| match (left, right) {
181                (
182                    crate::settings::SettingsNode::Workshop { children: left, .. },
183                    crate::settings::SettingsNode::Workshop {
184                        children: right, ..
185                    },
186                ) => nodes_equivalent(left, right),
187                (
188                    crate::settings::SettingsNode::Group {
189                        name: left_name,
190                        children: left_children,
191                        ..
192                    },
193                    crate::settings::SettingsNode::Group {
194                        name: right_name,
195                        children: right_children,
196                        ..
197                    },
198                ) => left_name == right_name && nodes_equivalent(left_children, right_children),
199                (
200                    crate::settings::SettingsNode::Number {
201                        name: left_name,
202                        value: left_value,
203                        ..
204                    },
205                    crate::settings::SettingsNode::Number {
206                        name: right_name,
207                        value: right_value,
208                        ..
209                    },
210                ) => left_name == right_name && float_equivalent(*left_value, *right_value),
211                (
212                    crate::settings::SettingsNode::Bool {
213                        name: left_name,
214                        value: left_value,
215                        ..
216                    },
217                    crate::settings::SettingsNode::Bool {
218                        name: right_name,
219                        value: right_value,
220                        ..
221                    },
222                ) => left_name == right_name && left_value == right_value,
223                (
224                    crate::settings::SettingsNode::Flag {
225                        name: left_name, ..
226                    },
227                    crate::settings::SettingsNode::Flag {
228                        name: right_name, ..
229                    },
230                ) => left_name == right_name,
231                (
232                    crate::settings::SettingsNode::String {
233                        name: left_name,
234                        value: left_value,
235                        ..
236                    },
237                    crate::settings::SettingsNode::String {
238                        name: right_name,
239                        value: right_value,
240                        ..
241                    },
242                ) => left_name == right_name && left_value == right_value,
243                (
244                    crate::settings::SettingsNode::List {
245                        name: left_name,
246                        elements: left_elements,
247                        ..
248                    },
249                    crate::settings::SettingsNode::List {
250                        name: right_name,
251                        elements: right_elements,
252                        ..
253                    },
254                ) => {
255                    left_name == right_name
256                        && left_elements.len() == right_elements.len()
257                        && left_elements
258                            .iter()
259                            .zip(right_elements)
260                            .all(|(left, right)| left.value == right.value)
261                }
262                (
263                    crate::settings::SettingsNode::Raw {
264                        name: left_name,
265                        value: left_value,
266                        ..
267                    },
268                    crate::settings::SettingsNode::Raw {
269                        name: right_name,
270                        value: right_value,
271                        ..
272                    },
273                ) => left_name == right_name && left_value == right_value,
274                _ => false,
275            })
276}
277
278fn float_equivalent(left: f64, right: f64) -> bool {
279    if left == right {
280        return true;
281    }
282    let scale = left.abs().max(right.abs()).max(1.0);
283    (left - right).abs() <= f64::EPSILON * scale * 4.0
284}
285
286fn rule_equivalent(
287    a: &wir::Program,
288    b: &wir::Program,
289    left: &wir::Rule,
290    right: &wir::Rule,
291) -> bool {
292    if left.name != right.name || left.disabled != right.disabled {
293        return false;
294    }
295    let event_a = event_equivalent(a, b, &left.event, &right.event);
296    if !event_a {
297        return false;
298    }
299    if left.conditions.len() != right.conditions.len() {
300        return false;
301    }
302    for (ca, cb) in left.conditions.iter().zip(right.conditions.iter()) {
303        if !value_equivalent(a, b, *ca, *cb) {
304            return false;
305        }
306    }
307    if left.actions.len() != right.actions.len() {
308        return false;
309    }
310    for (aa, ab) in left.actions.iter().zip(right.actions.iter()) {
311        if !action_equivalent(a, b, *aa, *ab) {
312            return false;
313        }
314    }
315    true
316}
317
318fn event_equivalent(
319    a: &wir::Program,
320    b: &wir::Program,
321    left: &wir::Event,
322    right: &wir::Event,
323) -> bool {
324    match (left, right) {
325        (wir::Event::Global, wir::Event::Global) => true,
326        (wir::Event::EachPlayer, wir::Event::EachPlayer) => true,
327        (wir::Event::EachPlayer, wir::Event::EachPlayerWithFilters { team, target })
328        | (wir::Event::EachPlayerWithFilters { team, target }, wir::Event::EachPlayer) => {
329            *team == wir::EventTeam::All && *target == wir::EventTarget::All
330        }
331        (
332            wir::Event::EachPlayerWithFilters {
333                team: team_a,
334                target: target_a,
335            },
336            wir::Event::EachPlayerWithFilters {
337                team: team_b,
338                target: target_b,
339            },
340        ) => team_a == team_b && target_a == target_b,
341        (
342            wir::Event::Player {
343                kind: kind_a,
344                team: team_a,
345                target: target_a,
346            },
347            wir::Event::Player {
348                kind: kind_b,
349                team: team_b,
350                target: target_b,
351            },
352        ) => kind_a == kind_b && team_a == team_b && target_a == target_b,
353        (wir::Event::Subroutine(sa), wir::Event::Subroutine(sb)) => {
354            let name_a = a.subroutines.get(*sa).map(|s| s.name.as_str());
355            let name_b = b.subroutines.get(*sb).map(|s| s.name.as_str());
356            name_a == name_b
357        }
358        _ => false,
359    }
360}
361
362fn action_equivalent(
363    a: &wir::Program,
364    b: &wir::Program,
365    left: wir::ActionId,
366    right: wir::ActionId,
367) -> bool {
368    let (Some(la), Some(rb)) = (a.actions.get(left), b.actions.get(right)) else {
369        return false;
370    };
371    match (la, rb) {
372        (
373            wir::Action::SetGlobalVariable {
374                variable: va,
375                value: x,
376                ..
377            },
378            wir::Action::SetGlobalVariable {
379                variable: vb,
380                value: y,
381                ..
382            },
383        ) => {
384            name_eq(a.global_variables.get(*va), b.global_variables.get(*vb))
385                && value_equivalent(a, b, *x, *y)
386        }
387        (
388            wir::Action::ModifyGlobalVariable {
389                variable: va,
390                op: oa,
391                value: x,
392                ..
393            },
394            wir::Action::ModifyGlobalVariable {
395                variable: vb,
396                op: ob,
397                value: y,
398                ..
399            },
400        ) => {
401            name_eq(a.global_variables.get(*va), b.global_variables.get(*vb))
402                && oa == ob
403                && value_equivalent(a, b, *x, *y)
404        }
405        (
406            wir::Action::SetPlayerVariable {
407                player: pa,
408                variable: va,
409                value: x,
410                ..
411            },
412            wir::Action::SetPlayerVariable {
413                player: pb,
414                variable: vb,
415                value: y,
416                ..
417            },
418        ) => {
419            value_equivalent(a, b, *pa, *pb)
420                && name_eq(a.player_variables.get(*va), b.player_variables.get(*vb))
421                && value_equivalent(a, b, *x, *y)
422        }
423        (
424            wir::Action::ModifyPlayerVariable {
425                player: pa,
426                variable: va,
427                op: oa,
428                value: x,
429                ..
430            },
431            wir::Action::ModifyPlayerVariable {
432                player: pb,
433                variable: vb,
434                op: ob,
435                value: y,
436                ..
437            },
438        ) => {
439            value_equivalent(a, b, *pa, *pb)
440                && name_eq(a.player_variables.get(*va), b.player_variables.get(*vb))
441                && oa == ob
442                && value_equivalent(a, b, *x, *y)
443        }
444        (
445            wir::Action::AssignMember {
446                target: ta,
447                op: oa,
448                value: xa,
449                ..
450            },
451            wir::Action::AssignMember {
452                target: tb,
453                op: ob,
454                value: xb,
455                ..
456            },
457        ) => oa == ob && value_equivalent(a, b, *ta, *tb) && value_equivalent(a, b, *xa, *xb),
458        (
459            wir::Action::CallSubroutine { subroutine: sa, .. },
460            wir::Action::CallSubroutine { subroutine: sb, .. },
461        ) => name_eq(a.subroutines.get(*sa), b.subroutines.get(*sb)),
462        (
463            wir::Action::If {
464                branches: ba,
465                else_body: ea,
466                ..
467            },
468            wir::Action::If {
469                branches: bb,
470                else_body: eb,
471                ..
472            },
473        ) => branches_equivalent(a, b, ba, bb) && bodies_equivalent(a, b, ea, eb),
474        (
475            wir::Action::While {
476                condition: ca,
477                body: ba,
478                ..
479            },
480            wir::Action::While {
481                condition: cb,
482                body: bb,
483                ..
484            },
485        ) => value_equivalent(a, b, *ca, *cb) && actions_equivalent(a, b, ba, bb),
486        (
487            wir::Action::ForGlobalVariable {
488                variable: va,
489                start: sa,
490                stop: ea,
491                step: pa,
492                body: ba,
493                ..
494            },
495            wir::Action::ForGlobalVariable {
496                variable: vb,
497                start: sb,
498                stop: eb,
499                step: pb,
500                body: bb,
501                ..
502            },
503        ) => {
504            name_eq(a.global_variables.get(*va), b.global_variables.get(*vb))
505                && value_equivalent(a, b, *sa, *sb)
506                && value_equivalent(a, b, *ea, *eb)
507                && value_equivalent(a, b, *pa, *pb)
508                && actions_equivalent(a, b, ba, bb)
509        }
510        (
511            wir::Action::ForPlayerVariable {
512                player: pa,
513                variable: va,
514                start: sa,
515                stop: ea,
516                step: sta,
517                body: ba,
518                ..
519            },
520            wir::Action::ForPlayerVariable {
521                player: pb,
522                variable: vb,
523                start: sb,
524                stop: eb,
525                step: stb,
526                body: bb,
527                ..
528            },
529        ) => {
530            value_equivalent(a, b, *pa, *pb)
531                && name_eq(a.player_variables.get(*va), b.player_variables.get(*vb))
532                && value_equivalent(a, b, *sa, *sb)
533                && value_equivalent(a, b, *ea, *eb)
534                && value_equivalent(a, b, *sta, *stb)
535                && actions_equivalent(a, b, ba, bb)
536        }
537        (
538            wir::Action::Call {
539                name: na, args: xa, ..
540            },
541            wir::Action::Call {
542                name: nb, args: xb, ..
543            },
544        ) => na == nb && values_equivalent(a, b, xa, xb),
545        _ => false,
546    }
547}
548
549fn branches_equivalent(
550    a: &wir::Program,
551    b: &wir::Program,
552    left: &[wir::IfBranch],
553    right: &[wir::IfBranch],
554) -> bool {
555    left.len() == right.len()
556        && left.iter().zip(right.iter()).all(|(la, rb)| {
557            value_equivalent(a, b, la.condition, rb.condition)
558                && actions_equivalent(a, b, &la.body, &rb.body)
559        })
560}
561
562fn actions_equivalent(
563    a: &wir::Program,
564    b: &wir::Program,
565    left: &[wir::ActionId],
566    right: &[wir::ActionId],
567) -> bool {
568    left.len() == right.len()
569        && left
570            .iter()
571            .zip(right.iter())
572            .all(|(la, rb)| action_equivalent(a, b, *la, *rb))
573}
574
575fn bodies_equivalent(
576    a: &wir::Program,
577    b: &wir::Program,
578    left: &Option<Vec<wir::ActionId>>,
579    right: &Option<Vec<wir::ActionId>>,
580) -> bool {
581    match (left, right) {
582        (Some(la), Some(rb)) => actions_equivalent(a, b, la, rb),
583        (None, None) => true,
584        _ => false,
585    }
586}
587
588fn value_equivalent(
589    a: &wir::Program,
590    b: &wir::Program,
591    left: wir::ValueId,
592    right: wir::ValueId,
593) -> bool {
594    let (Some(la), Some(rb)) = (a.values.get(left), b.values.get(right)) else {
595        return false;
596    };
597    match (&la.value, &rb.value) {
598        (wir::Value::Number { value: x, .. }, wir::Value::Number { value: y, .. }) => x == y,
599        (wir::Value::String(x), wir::Value::String(y)) => x == y,
600        (wir::Value::LocalizedString(x), wir::Value::LocalizedString(y)) => x == y,
601        (wir::Value::Bool(x), wir::Value::Bool(y)) => x == y,
602        (wir::Value::Null, wir::Value::Null) => true,
603        (wir::Value::Array(xa), wir::Value::Array(xb)) => values_equivalent(a, b, xa, xb),
604        (
605            wir::Value::Vector {
606                x: x1,
607                y: y1,
608                z: z1,
609            },
610            wir::Value::Vector {
611                x: x2,
612                y: y2,
613                z: z2,
614            },
615        ) => {
616            value_equivalent(a, b, *x1, *x2)
617                && value_equivalent(a, b, *y1, *y2)
618                && value_equivalent(a, b, *z1, *z2)
619        }
620        (
621            wir::Value::Enum {
622                value_type: t1,
623                value: v1,
624            },
625            wir::Value::Enum {
626                value_type: t2,
627                value: v2,
628            },
629        ) => t1 == t2 && v1 == v2,
630        (wir::Value::GlobalVariable(v1), wir::Value::GlobalVariable(v2)) => {
631            name_eq(a.global_variables.get(*v1), b.global_variables.get(*v2))
632        }
633        (
634            wir::Value::PlayerVariable {
635                player: p1,
636                variable: v1,
637            },
638            wir::Value::PlayerVariable {
639                player: p2,
640                variable: v2,
641            },
642        ) => {
643            value_equivalent(a, b, *p1, *p2)
644                && name_eq(a.player_variables.get(*v1), b.player_variables.get(*v2))
645        }
646        (wir::Value::Subroutine(s1), wir::Value::Subroutine(s2)) => {
647            name_eq(a.subroutines.get(*s1), b.subroutines.get(*s2))
648        }
649        (wir::Value::EventPlayer, wir::Value::EventPlayer) => true,
650        (wir::Value::PlayerVariable { player, variable }, wir::Value::Call { name, args })
651            if name == "memberAccess" && args.len() == 2 =>
652        {
653            let Some(wir::ValueNode {
654                value: wir::Value::String(member),
655                ..
656            }) = b.values.get(args[1])
657            else {
658                return false;
659            };
660            value_equivalent(a, b, *player, args[0])
661                && a.player_variables
662                    .get(*variable)
663                    .is_some_and(|value| value.name == *member)
664        }
665        (wir::Value::Call { name, args }, wir::Value::PlayerVariable { player, variable })
666            if name == "memberAccess" && args.len() == 2 =>
667        {
668            let Some(wir::ValueNode {
669                value: wir::Value::String(member),
670                ..
671            }) = a.values.get(args[1])
672            else {
673                return false;
674            };
675            value_equivalent(a, b, args[0], *player)
676                && b.player_variables
677                    .get(*variable)
678                    .is_some_and(|value| value.name == *member)
679        }
680        (wir::Value::Call { name: n1, args: x1 }, wir::Value::Call { name: n2, args: x2 }) => {
681            canonical_value_name(n1) == canonical_value_name(n2) && values_equivalent(a, b, x1, x2)
682        }
683        _ => false,
684    }
685}
686
687fn canonical_value_name(name: &str) -> &str {
688    match name {
689        "+" => "add",
690        "-" => "subtract",
691        "*" => "multiply",
692        "/" => "divide",
693        "len" => "countOf",
694        "abs" => "absoluteValue",
695        "sqrt" => "squareRoot",
696        _ => name,
697    }
698}
699
700fn values_equivalent(
701    a: &wir::Program,
702    b: &wir::Program,
703    left: &[wir::ValueId],
704    right: &[wir::ValueId],
705) -> bool {
706    left.len() == right.len()
707        && left
708            .iter()
709            .zip(right.iter())
710            .all(|(la, rb)| value_equivalent(a, b, *la, *rb))
711}
712
713fn name_eq<T: Named>(left: Option<&T>, right: Option<&T>) -> bool {
714    match (left, right) {
715        (Some(left), Some(right)) => left.name() == right.name(),
716        (None, None) => true,
717        _ => false,
718    }
719}
720
721trait Named {
722    fn name(&self) -> &str;
723}
724
725impl Named for wir::WorkshopVariable {
726    fn name(&self) -> &str {
727        &self.name
728    }
729}
730
731impl Named for wir::WorkshopSubroutine {
732    fn name(&self) -> &str {
733        &self.name
734    }
735}
736
737fn sha256(input: &str) -> String {
738    use sha2::{Digest, Sha256};
739    let mut hasher = Sha256::new();
740    hasher.update(input.as_bytes());
741    format!("{:x}", hasher.finalize())
742}