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