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