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)]
17#[non_exhaustive]
18pub struct RoundTripRecord {
19    /// SHA-256 of the input Workshop text.
20    pub input_identity: String,
21    /// The locale the text was parsed and emitted in.
22    pub locale: Locale,
23    /// The catalog schema version used.
24    pub catalog_version: u32,
25    /// Whether the input parsed.
26    pub parse_ok: bool,
27    /// Whether the parsed program emitted.
28    pub emit_ok: bool,
29    /// Whether the emitted text reparsed.
30    pub reparse_ok: bool,
31    /// Whether the original and round-tripped public programs are equivalent.
32    pub equivalent: bool,
33    /// A structured failure message, when any stage failed.
34    pub error: Option<String>,
35}
36
37/// Run `Workshop -> Program -> Workshop -> Program` and record the check. The record is
38/// always produced; failures are captured in its `error` field.
39/// Ambiguous bare enum members remain structured values (no signature context).
40pub fn round_trip(input: &str, catalog: &Catalog, locale: &Locale) -> RoundTripRecord {
41    round_trip_with_context(input, catalog, locale, &NoExpectedDomain)
42}
43
44/// The context-sensitive form of [`round_trip`] (#111): reparsing the emitted
45/// text uses the supplied canonical signature context so an ambiguous bare
46/// enum member that the emitter produced (e.g. `Chase Global Variable Over
47/// Time(..., None)`) resolves to the domain the signature pins.
48pub fn round_trip_with_context(
49    input: &str,
50    catalog: &Catalog,
51    locale: &Locale,
52    context: &dyn ExpectedDomain,
53) -> RoundTripRecord {
54    let input_identity = sha256(input);
55    let mut record = RoundTripRecord {
56        input_identity,
57        locale: locale.clone(),
58        catalog_version: catalog.schema_version(),
59        parse_ok: false,
60        emit_ok: false,
61        reparse_ok: false,
62        equivalent: false,
63        error: None,
64    };
65    let first = match parser::parse_with_context(input, catalog, locale, context) {
66        Ok(program) => program,
67        Err(error) => {
68            record.error = Some(error.to_string());
69            return record;
70        }
71    };
72    record.parse_ok = true;
73    let emitted = match emitter::emit(&first, catalog, locale) {
74        Ok(text) => text,
75        Err(error) => {
76            record.error = Some(error.to_string());
77            return record;
78        }
79    };
80    record.emit_ok = true;
81    let second = match parser::parse_with_context(&emitted, catalog, locale, context) {
82        Ok(program) => program,
83        Err(error) => {
84            record.error = Some(error.to_string());
85            return record;
86        }
87    };
88    record.reparse_ok = true;
89    record.equivalent = equivalent(&first, &second);
90    record
91}
92
93/// Structural equivalence of two public programs: identical settings, tables,
94/// rules, actions, and values, ignoring source spans and file paths.
95pub fn equivalent(a: &crate::Program, b: &crate::Program) -> bool {
96    let (Ok(a), Ok(b)) = (a.to_wir(), b.to_wir()) else {
97        return false;
98    };
99    equivalent_wir(&a, &b)
100}
101
102pub(crate) 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 ca.disabled != cb.disabled || !value_equivalent(a, b, ca.value, cb.value) {
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        (
362            wir::Event::Subroutine { subroutine: sa, .. },
363            wir::Event::Subroutine { subroutine: sb, .. },
364        ) => {
365            let name_a = a.subroutines.get(*sa).map(|s| s.name.as_str());
366            let name_b = b.subroutines.get(*sb).map(|s| s.name.as_str());
367            name_a == name_b
368        }
369        _ => false,
370    }
371}
372
373fn action_equivalent(
374    a: &wir::Program,
375    b: &wir::Program,
376    left: wir::ActionId,
377    right: wir::ActionId,
378) -> bool {
379    let (Some(la), Some(rb)) = (a.actions.get(left), b.actions.get(right)) else {
380        return false;
381    };
382    match (la, rb) {
383        (
384            wir::Action::SetGlobalVariable {
385                variable: va,
386                value: x,
387                ..
388            }
389            | wir::Action::ModifyGlobalVariable {
390                variable: va,
391                value: x,
392                ..
393            },
394            wir::Action::SetGlobalVariable {
395                variable: vb,
396                value: y,
397                ..
398            }
399            | wir::Action::ModifyGlobalVariable {
400                variable: vb,
401                value: y,
402                ..
403            },
404        ) => {
405            name_eq(a.global_variables.get(*va), b.global_variables.get(*vb))
406                && la.modify_op() == rb.modify_op()
407                && value_equivalent(a, b, *x, *y)
408        }
409        (
410            wir::Action::SetPlayerVariable {
411                player: pa,
412                variable: va,
413                value: x,
414                ..
415            }
416            | wir::Action::ModifyPlayerVariable {
417                player: pa,
418                variable: va,
419                value: x,
420                ..
421            },
422            wir::Action::SetPlayerVariable {
423                player: pb,
424                variable: vb,
425                value: y,
426                ..
427            }
428            | wir::Action::ModifyPlayerVariable {
429                player: pb,
430                variable: vb,
431                value: y,
432                ..
433            },
434        ) => {
435            value_equivalent(a, b, *pa, *pb)
436                && name_eq(a.player_variables.get(*va), b.player_variables.get(*vb))
437                && la.modify_op() == rb.modify_op()
438                && value_equivalent(a, b, *x, *y)
439        }
440        (
441            wir::Action::AssignMember {
442                target: ta,
443                op: oa,
444                value: xa,
445                ..
446            },
447            wir::Action::AssignMember {
448                target: tb,
449                op: ob,
450                value: xb,
451                ..
452            },
453        ) => oa == ob && value_equivalent(a, b, *ta, *tb) && value_equivalent(a, b, *xa, *xb),
454        (
455            wir::Action::CallSubroutine { subroutine: sa, .. },
456            wir::Action::CallSubroutine { subroutine: sb, .. },
457        ) => name_eq(a.subroutines.get(*sa), b.subroutines.get(*sb)),
458        (
459            wir::Action::If {
460                branches: ba,
461                else_body: ea,
462                ..
463            },
464            wir::Action::If {
465                branches: bb,
466                else_body: eb,
467                ..
468            },
469        ) => branches_equivalent(a, b, ba, bb) && bodies_equivalent(a, b, ea, eb),
470        (
471            wir::Action::While {
472                condition: ca,
473                body: ba,
474                ..
475            },
476            wir::Action::While {
477                condition: cb,
478                body: bb,
479                ..
480            },
481        ) => value_equivalent(a, b, *ca, *cb) && actions_equivalent(a, b, ba, bb),
482        (
483            wir::Action::ForGlobalVariable {
484                variable: va,
485                start: sa,
486                stop: ea,
487                step: pa,
488                body: ba,
489                ..
490            },
491            wir::Action::ForGlobalVariable {
492                variable: vb,
493                start: sb,
494                stop: eb,
495                step: pb,
496                body: bb,
497                ..
498            },
499        ) => {
500            name_eq(a.global_variables.get(*va), b.global_variables.get(*vb))
501                && value_equivalent(a, b, *sa, *sb)
502                && value_equivalent(a, b, *ea, *eb)
503                && value_equivalent(a, b, *pa, *pb)
504                && actions_equivalent(a, b, ba, bb)
505        }
506        (
507            wir::Action::ForPlayerVariable {
508                player: pa,
509                variable: va,
510                start: sa,
511                stop: ea,
512                step: sta,
513                body: ba,
514                ..
515            },
516            wir::Action::ForPlayerVariable {
517                player: pb,
518                variable: vb,
519                start: sb,
520                stop: eb,
521                step: stb,
522                body: bb,
523                ..
524            },
525        ) => {
526            value_equivalent(a, b, *pa, *pb)
527                && name_eq(a.player_variables.get(*va), b.player_variables.get(*vb))
528                && value_equivalent(a, b, *sa, *sb)
529                && value_equivalent(a, b, *ea, *eb)
530                && value_equivalent(a, b, *sta, *stb)
531                && actions_equivalent(a, b, ba, bb)
532        }
533        (wir::Action::Disabled { action: x, .. }, wir::Action::Disabled { action: y, .. }) => {
534            action_equivalent(a, b, *x, *y)
535        }
536        (
537            wir::Action::Call {
538                name: na, args: xa, ..
539            },
540            wir::Action::Call {
541                name: nb, args: xb, ..
542            },
543        ) => na == nb && values_equivalent(a, b, xa, xb),
544        _ => false,
545    }
546}
547
548fn branches_equivalent(
549    a: &wir::Program,
550    b: &wir::Program,
551    left: &[wir::IfBranch],
552    right: &[wir::IfBranch],
553) -> bool {
554    left.len() == right.len()
555        && left.iter().zip(right.iter()).all(|(la, rb)| {
556            value_equivalent(a, b, la.condition, rb.condition)
557                && actions_equivalent(a, b, &la.body, &rb.body)
558        })
559}
560
561fn actions_equivalent(
562    a: &wir::Program,
563    b: &wir::Program,
564    left: &[wir::ActionId],
565    right: &[wir::ActionId],
566) -> bool {
567    left.len() == right.len()
568        && left
569            .iter()
570            .zip(right.iter())
571            .all(|(la, rb)| action_equivalent(a, b, *la, *rb))
572}
573
574fn bodies_equivalent(
575    a: &wir::Program,
576    b: &wir::Program,
577    left: &Option<Vec<wir::ActionId>>,
578    right: &Option<Vec<wir::ActionId>>,
579) -> bool {
580    match (left, right) {
581        (Some(la), Some(rb)) => actions_equivalent(a, b, la, rb),
582        (None, None) => true,
583        _ => false,
584    }
585}
586
587fn value_equivalent(
588    a: &wir::Program,
589    b: &wir::Program,
590    left: wir::ValueId,
591    right: wir::ValueId,
592) -> bool {
593    let (Some(la), Some(rb)) = (a.values.get(left), b.values.get(right)) else {
594        return false;
595    };
596    match (&la.value, &rb.value) {
597        (wir::Value::Number { value: x, .. }, wir::Value::Number { value: y, .. }) => x == y,
598        (wir::Value::String(x), wir::Value::String(y)) => x == y,
599        (wir::Value::LocalizedString(x), wir::Value::LocalizedString(y)) => x == y,
600        (wir::Value::Bool(x), wir::Value::Bool(y)) => x == y,
601        (wir::Value::Null, wir::Value::Null) => true,
602        (wir::Value::Array(xa), wir::Value::Array(xb)) => values_equivalent(a, b, xa, xb),
603        (
604            wir::Value::Vector {
605                x: x1,
606                y: y1,
607                z: z1,
608            },
609            wir::Value::Vector {
610                x: x2,
611                y: y2,
612                z: z2,
613            },
614        ) => {
615            value_equivalent(a, b, *x1, *x2)
616                && value_equivalent(a, b, *y1, *y2)
617                && value_equivalent(a, b, *z1, *z2)
618        }
619        (
620            wir::Value::Enum {
621                value_type: t1,
622                value: v1,
623            },
624            wir::Value::Enum {
625                value_type: t2,
626                value: v2,
627            },
628        ) => t1 == t2 && v1 == v2,
629        (wir::Value::GlobalVariable(v1), wir::Value::GlobalVariable(v2)) => {
630            name_eq(a.global_variables.get(*v1), b.global_variables.get(*v2))
631        }
632        (
633            wir::Value::PlayerVariable {
634                player: p1,
635                variable: v1,
636            },
637            wir::Value::PlayerVariable {
638                player: p2,
639                variable: v2,
640            },
641        ) => {
642            value_equivalent(a, b, *p1, *p2)
643                && name_eq(a.player_variables.get(*v1), b.player_variables.get(*v2))
644        }
645        (wir::Value::Subroutine(s1), wir::Value::Subroutine(s2)) => {
646            name_eq(a.subroutines.get(*s1), b.subroutines.get(*s2))
647        }
648        (wir::Value::EventPlayer, wir::Value::EventPlayer) => true,
649        (wir::Value::PlayerVariable { player, variable }, wir::Value::Call { name, args })
650            if name == "memberAccess" && args.len() == 2 =>
651        {
652            let Some(wir::ValueNode {
653                value: wir::Value::String(member),
654                ..
655            }) = b.values.get(args[1])
656            else {
657                return false;
658            };
659            value_equivalent(a, b, *player, args[0])
660                && a.player_variables
661                    .get(*variable)
662                    .is_some_and(|value| value.name == *member)
663        }
664        (wir::Value::Call { name, args }, wir::Value::PlayerVariable { player, variable })
665            if name == "memberAccess" && args.len() == 2 =>
666        {
667            let Some(wir::ValueNode {
668                value: wir::Value::String(member),
669                ..
670            }) = a.values.get(args[1])
671            else {
672                return false;
673            };
674            value_equivalent(a, b, args[0], *player)
675                && b.player_variables
676                    .get(*variable)
677                    .is_some_and(|value| value.name == *member)
678        }
679        (wir::Value::Call { name: n1, args: x1 }, wir::Value::Call { name: n2, args: x2 })
680            if n1 == wir::AMBIGUOUS_ENUM_CALL && n2 == wir::AMBIGUOUS_ENUM_CALL =>
681        {
682            ambiguous_enum_equivalent(a, b, x1, x2)
683        }
684        (wir::Value::Call { name: n1, args: x1 }, wir::Value::Call { name: n2, args: x2 }) => {
685            canonical_value_name(n1) == canonical_value_name(n2) && values_equivalent(a, b, x1, x2)
686        }
687        _ => false,
688    }
689}
690
691fn ambiguous_enum_equivalent(
692    a: &wir::Program,
693    b: &wir::Program,
694    left: &[wir::ValueId],
695    right: &[wir::ValueId],
696) -> bool {
697    let Some((_, left_candidates)) = wir::ambiguous_enum_parts_by_args(a, left) else {
698        return false;
699    };
700    let Some((_, right_candidates)) = wir::ambiguous_enum_parts_by_args(b, right) else {
701        return false;
702    };
703    left_candidates == right_candidates
704}
705
706fn canonical_value_name(name: &str) -> &str {
707    match name {
708        "+" => "add",
709        "-" => "subtract",
710        "*" => "multiply",
711        "/" => "divide",
712        "len" => "countOf",
713        "abs" => "absoluteValue",
714        "sqrt" => "squareRoot",
715        _ => name,
716    }
717}
718
719fn values_equivalent(
720    a: &wir::Program,
721    b: &wir::Program,
722    left: &[wir::ValueId],
723    right: &[wir::ValueId],
724) -> bool {
725    left.len() == right.len()
726        && left
727            .iter()
728            .zip(right.iter())
729            .all(|(la, rb)| value_equivalent(a, b, *la, *rb))
730}
731
732fn name_eq<T: Named>(left: Option<&T>, right: Option<&T>) -> bool {
733    match (left, right) {
734        (Some(left), Some(right)) => left.name() == right.name(),
735        (None, None) => true,
736        _ => false,
737    }
738}
739
740trait Named {
741    fn name(&self) -> &str;
742}
743
744impl Named for wir::WorkshopVariable {
745    fn name(&self) -> &str {
746        &self.name
747    }
748}
749
750impl Named for wir::WorkshopSubroutine {
751    fn name(&self) -> &str {
752        &self.name
753    }
754}
755
756fn sha256(input: &str) -> String {
757    use sha2::{Digest, Sha256};
758    let mut hasher = Sha256::new();
759    hasher.update(input.as_bytes());
760    format!("{:x}", hasher.finalize())
761}