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