Skip to main content

workshop_rs/rules/
statement.rs

1//! Statement input for the `workshop-rs/rule-content-v1` format.
2//!
3//! [`statement_from_content`] is the read direction of the format's input
4//! side: it converts one condition or action list item — `{"value": node}` or
5//! `{"call": id, "args": [...]}`, with optional `"disabled": true` — into the
6//! public model. A statement read from [`crate::Rule::content`] is valid input
7//! unchanged, so rendering the converted statement again gives the same
8//! document. The format id and schema are unchanged.
9//!
10//! The conversion refuses, with a distinct [`StatementContentError`], every
11//! node it cannot turn into exactly the construct the node names: an unknown
12//! id, a wrong argument count, an internal sentinel, a node that forms no
13//! statement, and a node carrying spans. Nothing is approximated. A preserved
14//! ambiguous enum member is refused too: it carries a locale-dependent
15//! spelling, and the model value would not be the one a client wrote.
16
17use std::fmt;
18
19use serde_json::{Map, Value as JsonValue};
20
21use crate::catalog::{Catalog, CatalogEntry, Kind};
22use crate::program::{Action, Condition, ModifyOp, Value};
23use crate::rules::content::{
24    ASSIGN_MEMBER_CALL, GLOBAL_VARIABLE_CALL, MEMBER_ACCESS_CALL, MODIFY_MEMBER_CALL,
25    PLAYER_VARIABLE_CALL, RAW_WORKSHOP_ACTION_CALL,
26};
27use crate::wir::{self, AMBIGUOUS_ENUM_CALL};
28
29/// One rule statement in the public model: a condition or an action.
30#[derive(Debug, Clone)]
31#[non_exhaustive]
32pub enum Statement {
33    /// A condition list item, `{"value": node}`.
34    Condition(Condition),
35    /// An action list item, `{"call": id, "args": [...]}`. A `"disabled"`
36    /// modifier becomes [`Action::Disabled`].
37    Action(Action),
38}
39
40/// Why [`statement_from_content`] refused a node. Every variant names the
41/// offending node by its JSON pointer from the statement (`""` is the
42/// statement itself) and a copy of the node.
43#[derive(Debug, Clone, PartialEq)]
44#[non_exhaustive]
45pub enum StatementContentError {
46    /// A call id the catalog (and the format's reserved ids) does not name.
47    UnknownId {
48        id: String,
49        path: String,
50        node: JsonValue,
51    },
52    /// A call whose argument count the catalog signature does not accept.
53    /// `max` is `None` for a variadic signature.
54    ArgumentCount {
55        id: String,
56        min: usize,
57        max: Option<usize>,
58        supplied: usize,
59        path: String,
60        node: JsonValue,
61    },
62    /// An internal name (the ambiguous-enum helper) the format never exposes.
63    InternalSentinel {
64        id: String,
65        path: String,
66        node: JsonValue,
67    },
68    /// A node that forms no statement or value of the format.
69    Unrepresentable {
70        reason: String,
71        path: String,
72        node: JsonValue,
73    },
74    /// A node carrying spans, which are output-only.
75    Spans {
76        key: String,
77        path: String,
78        node: JsonValue,
79    },
80}
81
82impl StatementContentError {
83    /// The JSON pointer of the offending node, relative to the statement.
84    pub fn path(&self) -> &str {
85        match self {
86            Self::UnknownId { path, .. }
87            | Self::ArgumentCount { path, .. }
88            | Self::InternalSentinel { path, .. }
89            | Self::Unrepresentable { path, .. }
90            | Self::Spans { path, .. } => path,
91        }
92    }
93
94    pub fn node(&self) -> &JsonValue {
95        match self {
96            Self::UnknownId { node, .. }
97            | Self::ArgumentCount { node, .. }
98            | Self::InternalSentinel { node, .. }
99            | Self::Unrepresentable { node, .. }
100            | Self::Spans { node, .. } => node,
101        }
102    }
103}
104
105impl fmt::Display for StatementContentError {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        match self {
108            Self::UnknownId { id, path, .. } => {
109                write!(f, "unknown rule-content-v1 id '{id}' at '{path}'")
110            }
111            Self::ArgumentCount {
112                id,
113                min,
114                max,
115                supplied,
116                path,
117                ..
118            } => {
119                let max = max.map_or_else(|| "+".to_string(), |max| format!("..{max}"));
120                write!(
121                    f,
122                    "'{id}' at '{path}' expects {min}{max} argument(s), got {supplied}"
123                )
124            }
125            Self::InternalSentinel { id, path, .. } => {
126                write!(f, "internal name '{id}' at '{path}' is not rule-content-v1")
127            }
128            Self::Unrepresentable { reason, path, .. } => {
129                write!(f, "node at '{path}' is not representable: {reason}")
130            }
131            Self::Spans { key, path, .. } => {
132                write!(f, "'{key}' at '{path}': spans are output-only")
133            }
134        }
135    }
136}
137
138impl std::error::Error for StatementContentError {}
139
140type Result<T> = std::result::Result<T, StatementContentError>;
141
142/// Convert one `rule-content-v1` statement into the public model.
143///
144/// `catalog` decides which ids exist and how many arguments they take. The
145/// result is not validated as a program: whether the statement fits its
146/// rule (an `If` without its `End`, a value of the wrong type) is for
147/// canonical validation of the edited program.
148///
149/// ```
150/// use serde_json::json;
151/// use workshop_rs::catalog::Catalog;
152/// use workshop_rs::rules::statement::{Statement, statement_from_content};
153///
154/// let catalog = Catalog::builtin().unwrap();
155/// let statement = statement_from_content(
156///     &catalog,
157///     &json!({"call": "wait", "args": [0.5, {"enum": "Wait", "member": "IGNORE_CONDITION"}]}),
158/// )
159/// .unwrap();
160/// assert!(matches!(statement, Statement::Action(_)));
161/// ```
162pub fn statement_from_content(catalog: &Catalog, node: &JsonValue) -> Result<Statement> {
163    let converter = Converter { catalog };
164    let object = converter.object(node, "")?;
165    if object.contains_key("value") {
166        converter.condition(object, node)
167    } else if object.contains_key("call") {
168        converter.action(object, node)
169    } else {
170        Err(unrepresentable(
171            "a statement has a 'value' (condition) or a 'call' (action)",
172            "",
173            node,
174        ))
175    }
176}
177
178struct Converter<'a> {
179    catalog: &'a Catalog,
180}
181
182fn unrepresentable(
183    reason: impl Into<String>,
184    path: &str,
185    node: &JsonValue,
186) -> StatementContentError {
187    StatementContentError::Unrepresentable {
188        reason: reason.into(),
189        path: path.to_string(),
190        node: node.clone(),
191    }
192}
193
194fn child(path: &str, segment: impl fmt::Display) -> String {
195    format!("{path}/{segment}")
196}
197
198impl Converter<'_> {
199    fn object<'n>(&self, node: &'n JsonValue, path: &str) -> Result<&'n Map<String, JsonValue>> {
200        let Some(object) = node.as_object() else {
201            return Err(unrepresentable("expected a JSON object", path, node));
202        };
203        for key in ["span", "spans"] {
204            if object.contains_key(key) {
205                return Err(StatementContentError::Spans {
206                    key: key.to_string(),
207                    path: path.to_string(),
208                    node: node.clone(),
209                });
210            }
211        }
212        Ok(object)
213    }
214
215    fn only_keys(
216        &self,
217        object: &Map<String, JsonValue>,
218        allowed: &[&str],
219        path: &str,
220        node: &JsonValue,
221    ) -> Result<()> {
222        match object.keys().find(|key| !allowed.contains(&key.as_str())) {
223            Some(key) => Err(unrepresentable(
224                format!("unexpected key '{key}'"),
225                path,
226                node,
227            )),
228            None => Ok(()),
229        }
230    }
231
232    fn disabled(&self, object: &Map<String, JsonValue>, node: &JsonValue) -> Result<bool> {
233        match object.get("disabled") {
234            None => Ok(false),
235            Some(JsonValue::Bool(true)) => Ok(true),
236            Some(_) => Err(unrepresentable(
237                "'disabled' is present only as true",
238                "",
239                node,
240            )),
241        }
242    }
243
244    fn condition(&self, object: &Map<String, JsonValue>, node: &JsonValue) -> Result<Statement> {
245        self.only_keys(object, &["value", "disabled"], "", node)?;
246        let disabled = self.disabled(object, node)?;
247        let value = self.value(&object["value"], "/value")?;
248        Ok(Statement::Condition(if disabled {
249            Condition::disabled(value)
250        } else {
251            Condition::new(value)
252        }))
253    }
254
255    fn action(&self, object: &Map<String, JsonValue>, node: &JsonValue) -> Result<Statement> {
256        self.only_keys(object, &["call", "args", "disabled"], "", node)?;
257        let disabled = self.disabled(object, node)?;
258        let action = self.action_call(object, node, "")?;
259        Ok(Statement::Action(if disabled {
260            Action::disabled(action)
261        } else {
262            action
263        }))
264    }
265
266    fn call_parts<'n>(
267        &self,
268        object: &'n Map<String, JsonValue>,
269        node: &JsonValue,
270        path: &str,
271    ) -> Result<(&'n str, &'n [JsonValue])> {
272        let id = object
273            .get("call")
274            .and_then(JsonValue::as_str)
275            .ok_or_else(|| unrepresentable("'call' must be a string id", path, node))?;
276        let args = object
277            .get("args")
278            .and_then(JsonValue::as_array)
279            .ok_or_else(|| unrepresentable("a call node needs an 'args' array", path, node))?;
280        if id == AMBIGUOUS_ENUM_CALL {
281            return Err(StatementContentError::InternalSentinel {
282                id: id.to_string(),
283                path: path.to_string(),
284                node: node.clone(),
285            });
286        }
287        Ok((id, args))
288    }
289
290    fn action_call(
291        &self,
292        object: &Map<String, JsonValue>,
293        node: &JsonValue,
294        path: &str,
295    ) -> Result<Action> {
296        let (id, args) = self.call_parts(object, node, path)?;
297        let arg_path = |index: usize| child(&child(path, "args"), index);
298        let unknown = || StatementContentError::UnknownId {
299            id: id.to_string(),
300            path: path.to_string(),
301            node: node.clone(),
302        };
303        let count = |min: usize, max: usize| -> Result<()> {
304            if (min..=max).contains(&args.len()) {
305                Ok(())
306            } else {
307                Err(StatementContentError::ArgumentCount {
308                    id: id.to_string(),
309                    min,
310                    max: Some(max),
311                    supplied: args.len(),
312                    path: path.to_string(),
313                    node: node.clone(),
314                })
315            }
316        };
317        let value = |index: usize| self.value(&args[index], &arg_path(index));
318        let variable = |index: usize| self.variable(&args[index], &arg_path(index));
319        let op = |index: usize| self.modify_op(&args[index], &arg_path(index));
320
321        let action = match id {
322            RAW_WORKSHOP_ACTION_CALL => {
323                return Err(unrepresentable(
324                    "an opaque raw Workshop action has no canonical content",
325                    path,
326                    node,
327                ));
328            }
329            "setGlobalVariable" => {
330                count(2, 2)?;
331                Action::SetGlobalVariable {
332                    variable: variable(0)?,
333                    value: value(1)?,
334                }
335            }
336            "modifyGlobalVariable" => {
337                count(3, 3)?;
338                Action::ModifyGlobalVariable {
339                    variable: variable(0)?,
340                    op: op(1)?,
341                    value: value(2)?,
342                }
343            }
344            "setPlayerVariable" => {
345                count(3, 3)?;
346                Action::SetPlayerVariable {
347                    player: value(0)?,
348                    variable: variable(1)?,
349                    value: value(2)?,
350                }
351            }
352            "modifyPlayerVariable" => {
353                count(4, 4)?;
354                Action::ModifyPlayerVariable {
355                    player: value(0)?,
356                    variable: variable(1)?,
357                    op: op(2)?,
358                    value: value(3)?,
359                }
360            }
361            ASSIGN_MEMBER_CALL => {
362                count(2, 2)?;
363                Action::AssignMember {
364                    target: value(0)?,
365                    op: None,
366                    value: value(1)?,
367                }
368            }
369            MODIFY_MEMBER_CALL => {
370                count(3, 3)?;
371                Action::AssignMember {
372                    target: value(0)?,
373                    op: Some(op(1)?),
374                    value: value(2)?,
375                }
376            }
377            "callSubroutine" => {
378                count(1, 1)?;
379                let JsonValue::Object(subroutine) = &args[0] else {
380                    return Err(unrepresentable(
381                        "expected a {\"subroutine\"} node",
382                        &arg_path(0),
383                        &args[0],
384                    ));
385                };
386                self.object(&args[0], &arg_path(0))?;
387                let name = subroutine.get("subroutine").and_then(JsonValue::as_str);
388                match (name, subroutine.len()) {
389                    (Some(name), 1) => Action::CallSubroutine {
390                        subroutine: name.to_string(),
391                    },
392                    _ => {
393                        return Err(unrepresentable(
394                            "expected a {\"subroutine\"} node",
395                            &arg_path(0),
396                            &args[0],
397                        ));
398                    }
399                }
400            }
401            "if" => {
402                count(1, 1)?;
403                Action::If {
404                    condition: value(0)?,
405                }
406            }
407            "elseIf" => {
408                count(1, 1)?;
409                Action::ElseIf {
410                    condition: value(0)?,
411                }
412            }
413            "while" => {
414                count(1, 1)?;
415                Action::While {
416                    condition: value(0)?,
417                }
418            }
419            "else" => {
420                count(0, 0)?;
421                Action::Else
422            }
423            "end" => {
424                count(0, 0)?;
425                Action::End
426            }
427            "forGlobalVariable" => {
428                count(4, 4)?;
429                Action::ForGlobalVariable {
430                    variable: variable(0)?,
431                    start: value(1)?,
432                    stop: value(2)?,
433                    step: value(3)?,
434                }
435            }
436            "forPlayerVariable" => {
437                count(5, 5)?;
438                Action::ForPlayerVariable {
439                    player: value(0)?,
440                    variable: variable(1)?,
441                    start: value(2)?,
442                    stop: value(3)?,
443                    step: value(4)?,
444                }
445            }
446            _ => {
447                let entry = self.catalog.entry(Kind::Action, id).ok_or_else(unknown)?;
448                let values = self.values(args, path)?;
449                self.check_arity(entry, &values, node, path)?;
450                let name = match (id, values.first()) {
451                    ("chasePlayerVariableAtRate", Some(Value::PlayerVariable { .. })) => {
452                        "chaseAtRate"
453                    }
454                    ("chasePlayerVariableOverTime", Some(Value::PlayerVariable { .. })) => {
455                        "chaseOverTime"
456                    }
457                    ("chasePlayerVariableAtRate" | "chasePlayerVariableOverTime", _) => {
458                        return Err(unrepresentable(
459                            "a player-variable chase takes a playerVariable target",
460                            &arg_path(0),
461                            &args[0],
462                        ));
463                    }
464                    ("chaseAtRate" | "chaseOverTime", Some(Value::PlayerVariable { .. })) => {
465                        return Err(unrepresentable(
466                            "a playerVariable chase is named chasePlayerVariable…",
467                            path,
468                            node,
469                        ));
470                    }
471                    _ => id,
472                };
473                Action::call(name, values)
474            }
475        };
476        Ok(action)
477    }
478
479    fn check_arity(
480        &self,
481        entry: &CatalogEntry,
482        values: &[Value],
483        node: &JsonValue,
484        path: &str,
485    ) -> Result<()> {
486        let folded = entry.folds_player_variable_pair()
487            && matches!(values.first(), Some(Value::PlayerVariable { .. }));
488        let supplied = values.len() + usize::from(folded);
489        if let Some((min, max)) = entry.rejected_arity(supplied) {
490            return Err(StatementContentError::ArgumentCount {
491                id: entry.id.clone(),
492                min,
493                max,
494                supplied,
495                path: path.to_string(),
496                node: node.clone(),
497            });
498        }
499        Ok(())
500    }
501
502    fn variable(&self, node: &JsonValue, path: &str) -> Result<String> {
503        let object = self.object(node, path)?;
504        match (
505            object.get("variable").and_then(JsonValue::as_str),
506            object.len(),
507        ) {
508            (Some(name), 1) => Ok(name.to_string()),
509            _ => Err(unrepresentable(
510                "expected a {\"variable\"} node",
511                path,
512                node,
513            )),
514        }
515    }
516
517    fn modify_op(&self, node: &JsonValue, path: &str) -> Result<ModifyOp> {
518        let object = self.object(node, path)?;
519        let (id, args) = self.call_parts(object, node, path)?;
520        self.only_keys(object, &["call", "args"], path, node)?;
521        if !args.is_empty() {
522            return Err(StatementContentError::ArgumentCount {
523                id: id.to_string(),
524                min: 0,
525                max: Some(0),
526                supplied: args.len(),
527                path: path.to_string(),
528                node: node.clone(),
529            });
530        }
531        ModifyOp::from_catalog_id(id)
532            .ok_or_else(|| unrepresentable(format!("'{id}' is not a modify operation"), path, node))
533    }
534
535    fn value(&self, node: &JsonValue, path: &str) -> Result<Value> {
536        match node {
537            JsonValue::Number(number) => Ok(Value::Number(number.as_f64().ok_or_else(|| {
538                unrepresentable("number is not representable as f64", path, node)
539            })?)),
540            JsonValue::Bool(value) => Ok(Value::Bool(*value)),
541            JsonValue::Null => Ok(Value::Null),
542            JsonValue::Object(object) => self.object_value(object, node, path),
543            _ => Err(unrepresentable("expected a value node", path, node)),
544        }
545    }
546
547    fn object_value(
548        &self,
549        object: &Map<String, JsonValue>,
550        node: &JsonValue,
551        path: &str,
552    ) -> Result<Value> {
553        self.object(node, path)?;
554        let text = |key: &str| object.get(key).and_then(JsonValue::as_str);
555        if object.contains_key("call") {
556            self.only_keys(object, &["call", "args"], path, node)?;
557            return self.value_call(object, node, path);
558        }
559        let tagged = |keys: &[&str]| -> Result<()> {
560            if object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key)) {
561                Ok(())
562            } else {
563                Err(unrepresentable("malformed tagged node", path, node))
564            }
565        };
566        if object.contains_key("string") {
567            tagged(&["string"])?;
568            let string = text("string")
569                .ok_or_else(|| unrepresentable("'string' must be text", path, node))?;
570            return Ok(Value::string(string));
571        }
572        if object.contains_key("localizedString") {
573            tagged(&["localizedString"])?;
574            let id = text("localizedString")
575                .ok_or_else(|| unrepresentable("'localizedString' must be an id", path, node))?;
576            if !self.catalog.localized_strings().any(|entry| entry.id == id) {
577                return Err(StatementContentError::UnknownId {
578                    id: id.to_string(),
579                    path: path.to_string(),
580                    node: node.clone(),
581                });
582            }
583            return Ok(Value::LocalizedString(id.to_string()));
584        }
585        if object.contains_key("enum") {
586            tagged(&["enum", "member"])?;
587            let (Some(domain), Some(member)) = (text("enum"), text("member")) else {
588                return Err(unrepresentable("enum and member must be text", path, node));
589            };
590            let known = self
591                .catalog
592                .enum_domain(domain)
593                .is_some_and(|domain| domain.members.iter().any(|m| m.member == member));
594            if !known {
595                return Err(StatementContentError::UnknownId {
596                    id: format!("{domain}.{member}"),
597                    path: path.to_string(),
598                    node: node.clone(),
599                });
600            }
601            return Ok(Value::Enum {
602                value_type: domain.to_string(),
603                value: member.to_string(),
604            });
605        }
606        if object.contains_key("ambiguous") {
607            return Err(unrepresentable(
608                "an ambiguous enum member has no canonical identity to write",
609                path,
610                node,
611            ));
612        }
613        if object.contains_key("subroutine") {
614            tagged(&["subroutine"])?;
615            let name = text("subroutine")
616                .ok_or_else(|| unrepresentable("'subroutine' must be a name", path, node))?;
617            return Ok(Value::Subroutine(name.to_string()));
618        }
619        Err(unrepresentable("unknown node form", path, node))
620    }
621
622    fn value_call(
623        &self,
624        object: &Map<String, JsonValue>,
625        node: &JsonValue,
626        path: &str,
627    ) -> Result<Value> {
628        let (id, args) = self.call_parts(object, node, path)?;
629        let arg_path = |index: usize| child(&child(path, "args"), index);
630        let count_error = |min: usize, max: Option<usize>| StatementContentError::ArgumentCount {
631            id: id.to_string(),
632            min,
633            max,
634            supplied: args.len(),
635            path: path.to_string(),
636            node: node.clone(),
637        };
638        match id {
639            GLOBAL_VARIABLE_CALL => {
640                if args.len() != 1 {
641                    return Err(count_error(1, Some(1)));
642                }
643                Ok(Value::GlobalVariable(
644                    self.variable(&args[0], &arg_path(0))?,
645                ))
646            }
647            PLAYER_VARIABLE_CALL => {
648                if args.len() != 2 {
649                    return Err(count_error(2, Some(2)));
650                }
651                Ok(Value::player_variable(
652                    self.value(&args[0], &arg_path(0))?,
653                    self.variable(&args[1], &arg_path(1))?,
654                ))
655            }
656            MEMBER_ACCESS_CALL => {
657                if !(2..=3).contains(&args.len()) {
658                    return Err(count_error(2, Some(3)));
659                }
660                let values = self.values(args, path)?;
661                if !matches!(values[1], Value::String(_)) {
662                    return Err(unrepresentable(
663                        "memberAccess member must be a string",
664                        &arg_path(1),
665                        &args[1],
666                    ));
667                }
668                Ok(Value::Call {
669                    name: id.to_string(),
670                    args: values,
671                })
672            }
673            _ => {
674                let values = self.values(args, path)?;
675                let operand = args.is_empty() && wir::is_operation_helper(id);
676                if !operand && let Some(entry) = self.catalog.entry(Kind::Value, id) {
677                    self.check_arity(entry, &values, node, path)?;
678                    return Ok(self.shaped_call(id, values));
679                }
680                if self.catalog.entry(Kind::Operator, id).is_some()
681                    || wir::is_canonical_helper_call(id, args.len())
682                {
683                    return Ok(Value::Call {
684                        name: id.to_string(),
685                        args: values,
686                    });
687                }
688                if wir::is_canonical_helper(id) {
689                    return Err(count_error(0, Some(0)));
690                }
691                Err(StatementContentError::UnknownId {
692                    id: id.to_string(),
693                    path: path.to_string(),
694                    node: node.clone(),
695                })
696            }
697        }
698    }
699
700    fn values(&self, args: &[JsonValue], path: &str) -> Result<Vec<Value>> {
701        args.iter()
702            .enumerate()
703            .map(|(index, arg)| self.value(arg, &child(&child(path, "args"), index)))
704            .collect()
705    }
706
707    fn shaped_call(&self, id: &str, mut values: Vec<Value>) -> Value {
708        match (id, values.len()) {
709            ("eventPlayer", 0) => Value::EventPlayer,
710            ("array", _) => Value::Array(values),
711            ("vector", 3) => {
712                let z = Box::new(values.pop().expect("three values"));
713                let y = Box::new(values.pop().expect("three values"));
714                let x = Box::new(values.pop().expect("three values"));
715                Value::Vector { x, y, z }
716            }
717            _ => Value::call(id, values),
718        }
719    }
720}