Skip to main content

workshop_rs/
roundtrip.rs

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