diff --git a/crates/workshop-rs/src/rules/mod.rs b/crates/workshop-rs/src/rules/mod.rs index 55e88b8..fcca7f9 100644 --- a/crates/workshop-rs/src/rules/mod.rs +++ b/crates/workshop-rs/src/rules/mod.rs @@ -8,6 +8,7 @@ pub mod content; pub(crate) mod emitter; pub(crate) mod parser; +pub mod statement; pub(crate) mod validate; pub use crate::analysis::semantic::{ diff --git a/crates/workshop-rs/src/rules/statement.rs b/crates/workshop-rs/src/rules/statement.rs new file mode 100644 index 0000000..98e35eb --- /dev/null +++ b/crates/workshop-rs/src/rules/statement.rs @@ -0,0 +1,732 @@ +//! Statement input for the `workshop-rs/rule-content-v1` format. +//! +//! [`statement_from_content`] is the read direction of the format's input +//! side: it converts one condition or action list item — `{"value": node}` or +//! `{"call": id, "args": [...]}`, with optional `"disabled": true` — into the +//! public model. A statement read from [`crate::Rule::content`] is valid input +//! unchanged, so rendering the converted statement again gives the same +//! document. The format id and schema are unchanged. +//! +//! The conversion refuses, with a distinct [`StatementContentError`], every +//! node it cannot turn into exactly the construct the node names: an unknown +//! id, a wrong argument count, an internal sentinel, a node that forms no +//! statement, and a node carrying spans. Nothing is approximated. A preserved +//! ambiguous enum member is refused too: it carries a locale-dependent +//! spelling, and the model value would not be the one a client wrote. + +use std::fmt; + +use serde_json::{Map, Value as JsonValue}; + +use crate::catalog::{Catalog, CatalogEntry, Kind}; +use crate::program::{Action, Condition, ModifyOp, Value}; +use crate::rules::content::{ + ASSIGN_MEMBER_CALL, GLOBAL_VARIABLE_CALL, MEMBER_ACCESS_CALL, MODIFY_MEMBER_CALL, + PLAYER_VARIABLE_CALL, RAW_WORKSHOP_ACTION_CALL, +}; +use crate::wir::{self, AMBIGUOUS_ENUM_CALL}; + +/// One rule statement in the public model: a condition or an action. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Statement { + /// A condition list item, `{"value": node}`. + Condition(Condition), + /// An action list item, `{"call": id, "args": [...]}`. A `"disabled"` + /// modifier becomes [`Action::Disabled`]. + Action(Action), +} + +/// Why [`statement_from_content`] refused a node. Every variant names the +/// offending node by its JSON pointer from the statement (`""` is the +/// statement itself) and a copy of the node. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum StatementContentError { + /// A call id the catalog (and the format's reserved ids) does not name. + UnknownId { + id: String, + path: String, + node: JsonValue, + }, + /// A call whose argument count the catalog signature does not accept. + /// `max` is `None` for a variadic signature. + ArgumentCount { + id: String, + min: usize, + max: Option, + supplied: usize, + path: String, + node: JsonValue, + }, + /// An internal name (the ambiguous-enum helper) the format never exposes. + InternalSentinel { + id: String, + path: String, + node: JsonValue, + }, + /// A node that forms no statement or value of the format. + Unrepresentable { + reason: String, + path: String, + node: JsonValue, + }, + /// A node carrying spans, which are output-only. + Spans { + key: String, + path: String, + node: JsonValue, + }, +} + +impl StatementContentError { + /// The JSON pointer of the offending node, relative to the statement. + pub fn path(&self) -> &str { + match self { + Self::UnknownId { path, .. } + | Self::ArgumentCount { path, .. } + | Self::InternalSentinel { path, .. } + | Self::Unrepresentable { path, .. } + | Self::Spans { path, .. } => path, + } + } + + /// The offending node. + pub fn node(&self) -> &JsonValue { + match self { + Self::UnknownId { node, .. } + | Self::ArgumentCount { node, .. } + | Self::InternalSentinel { node, .. } + | Self::Unrepresentable { node, .. } + | Self::Spans { node, .. } => node, + } + } +} + +impl fmt::Display for StatementContentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownId { id, path, .. } => { + write!(f, "unknown rule-content-v1 id '{id}' at '{path}'") + } + Self::ArgumentCount { + id, + min, + max, + supplied, + path, + .. + } => { + let max = max.map_or_else(|| "+".to_string(), |max| format!("..{max}")); + write!( + f, + "'{id}' at '{path}' expects {min}{max} argument(s), got {supplied}" + ) + } + Self::InternalSentinel { id, path, .. } => { + write!(f, "internal name '{id}' at '{path}' is not rule-content-v1") + } + Self::Unrepresentable { reason, path, .. } => { + write!(f, "node at '{path}' is not representable: {reason}") + } + Self::Spans { key, path, .. } => { + write!(f, "'{key}' at '{path}': spans are output-only") + } + } + } +} + +impl std::error::Error for StatementContentError {} + +type Result = std::result::Result; + +/// Convert one `rule-content-v1` statement into the public model. +/// +/// `catalog` decides which ids exist and how many arguments they take. The +/// result is not validated as a program: whether the statement fits its +/// rule (an `If` without its `End`, a value of the wrong type) is for +/// canonical validation of the edited program. +/// +/// ``` +/// use serde_json::json; +/// use workshop_rs::catalog::Catalog; +/// use workshop_rs::rules::statement::{Statement, statement_from_content}; +/// +/// let catalog = Catalog::builtin().unwrap(); +/// let statement = statement_from_content( +/// &catalog, +/// &json!({"call": "wait", "args": [0.5, {"enum": "WaitBehavior", "member": "IGNORE_CONDITION"}]}), +/// ) +/// .unwrap(); +/// assert!(matches!(statement, Statement::Action(_))); +/// ``` +pub fn statement_from_content(catalog: &Catalog, node: &JsonValue) -> Result { + let converter = Converter { catalog }; + let object = converter.object(node, "")?; + if object.contains_key("value") { + converter.condition(object, node) + } else if object.contains_key("call") { + converter.action(object, node) + } else { + Err(unrepresentable( + "a statement has a 'value' (condition) or a 'call' (action)", + "", + node, + )) + } +} + +struct Converter<'a> { + catalog: &'a Catalog, +} + +fn unrepresentable( + reason: impl Into, + path: &str, + node: &JsonValue, +) -> StatementContentError { + StatementContentError::Unrepresentable { + reason: reason.into(), + path: path.to_string(), + node: node.clone(), + } +} + +fn child(path: &str, segment: impl fmt::Display) -> String { + format!("{path}/{segment}") +} + +impl Converter<'_> { + /// The node as an object, after rejecting span keys. + fn object<'n>(&self, node: &'n JsonValue, path: &str) -> Result<&'n Map> { + let Some(object) = node.as_object() else { + return Err(unrepresentable("expected a JSON object", path, node)); + }; + for key in ["span", "spans"] { + if object.contains_key(key) { + return Err(StatementContentError::Spans { + key: key.to_string(), + path: path.to_string(), + node: node.clone(), + }); + } + } + Ok(object) + } + + fn only_keys( + &self, + object: &Map, + allowed: &[&str], + path: &str, + node: &JsonValue, + ) -> Result<()> { + match object.keys().find(|key| !allowed.contains(&key.as_str())) { + Some(key) => Err(unrepresentable( + format!("unexpected key '{key}'"), + path, + node, + )), + None => Ok(()), + } + } + + fn disabled(&self, object: &Map, node: &JsonValue) -> Result { + match object.get("disabled") { + None => Ok(false), + Some(JsonValue::Bool(true)) => Ok(true), + Some(_) => Err(unrepresentable( + "'disabled' is present only as true", + "", + node, + )), + } + } + + fn condition(&self, object: &Map, node: &JsonValue) -> Result { + self.only_keys(object, &["value", "disabled"], "", node)?; + let disabled = self.disabled(object, node)?; + let value = self.value(&object["value"], "/value")?; + Ok(Statement::Condition(if disabled { + Condition::disabled(value) + } else { + Condition::new(value) + })) + } + + fn action(&self, object: &Map, node: &JsonValue) -> Result { + self.only_keys(object, &["call", "args", "disabled"], "", node)?; + let disabled = self.disabled(object, node)?; + let action = self.action_call(object, node, "")?; + Ok(Statement::Action(if disabled { + Action::disabled(action) + } else { + action + })) + } + + /// The `call` id and `args` list of a call node. + fn call_parts<'n>( + &self, + object: &'n Map, + node: &JsonValue, + path: &str, + ) -> Result<(&'n str, &'n [JsonValue])> { + let id = object + .get("call") + .and_then(JsonValue::as_str) + .ok_or_else(|| unrepresentable("'call' must be a string id", path, node))?; + let args = object + .get("args") + .and_then(JsonValue::as_array) + .ok_or_else(|| unrepresentable("a call node needs an 'args' array", path, node))?; + if id == AMBIGUOUS_ENUM_CALL { + return Err(StatementContentError::InternalSentinel { + id: id.to_string(), + path: path.to_string(), + node: node.clone(), + }); + } + Ok((id, args)) + } + + fn action_call( + &self, + object: &Map, + node: &JsonValue, + path: &str, + ) -> Result { + let (id, args) = self.call_parts(object, node, path)?; + let arg_path = |index: usize| child(&child(path, "args"), index); + let unknown = || StatementContentError::UnknownId { + id: id.to_string(), + path: path.to_string(), + node: node.clone(), + }; + let count = |min: usize, max: usize| -> Result<()> { + if (min..=max).contains(&args.len()) { + Ok(()) + } else { + Err(StatementContentError::ArgumentCount { + id: id.to_string(), + min, + max: Some(max), + supplied: args.len(), + path: path.to_string(), + node: node.clone(), + }) + } + }; + let value = |index: usize| self.value(&args[index], &arg_path(index)); + let variable = |index: usize| self.variable(&args[index], &arg_path(index)); + let op = |index: usize| self.modify_op(&args[index], &arg_path(index)); + + let action = match id { + RAW_WORKSHOP_ACTION_CALL => { + return Err(unrepresentable( + "an opaque raw Workshop action has no canonical content", + path, + node, + )); + } + "setGlobalVariable" => { + count(2, 2)?; + Action::SetGlobalVariable { + variable: variable(0)?, + value: value(1)?, + } + } + "modifyGlobalVariable" => { + count(3, 3)?; + Action::ModifyGlobalVariable { + variable: variable(0)?, + op: op(1)?, + value: value(2)?, + } + } + "setPlayerVariable" => { + count(3, 3)?; + Action::SetPlayerVariable { + player: value(0)?, + variable: variable(1)?, + value: value(2)?, + } + } + "modifyPlayerVariable" => { + count(4, 4)?; + Action::ModifyPlayerVariable { + player: value(0)?, + variable: variable(1)?, + op: op(2)?, + value: value(3)?, + } + } + ASSIGN_MEMBER_CALL => { + count(2, 2)?; + Action::AssignMember { + target: value(0)?, + op: None, + value: value(1)?, + } + } + MODIFY_MEMBER_CALL => { + count(3, 3)?; + Action::AssignMember { + target: value(0)?, + op: Some(op(1)?), + value: value(2)?, + } + } + "callSubroutine" => { + count(1, 1)?; + let JsonValue::Object(subroutine) = &args[0] else { + return Err(unrepresentable( + "expected a {\"subroutine\"} node", + &arg_path(0), + &args[0], + )); + }; + self.object(&args[0], &arg_path(0))?; + let name = subroutine.get("subroutine").and_then(JsonValue::as_str); + match (name, subroutine.len()) { + (Some(name), 1) => Action::CallSubroutine { + subroutine: name.to_string(), + }, + _ => { + return Err(unrepresentable( + "expected a {\"subroutine\"} node", + &arg_path(0), + &args[0], + )); + } + } + } + "if" => { + count(1, 1)?; + Action::If { + condition: value(0)?, + } + } + "elseIf" => { + count(1, 1)?; + Action::ElseIf { + condition: value(0)?, + } + } + "while" => { + count(1, 1)?; + Action::While { + condition: value(0)?, + } + } + "else" => { + count(0, 0)?; + Action::Else + } + "end" => { + count(0, 0)?; + Action::End + } + "forGlobalVariable" => { + count(4, 4)?; + Action::ForGlobalVariable { + variable: variable(0)?, + start: value(1)?, + stop: value(2)?, + step: value(3)?, + } + } + "forPlayerVariable" => { + count(5, 5)?; + Action::ForPlayerVariable { + player: value(0)?, + variable: variable(1)?, + start: value(2)?, + stop: value(3)?, + step: value(4)?, + } + } + _ => { + let entry = self.catalog.entry(Kind::Action, id).ok_or_else(unknown)?; + let values = self.values(args, path)?; + self.check_arity(entry, &values, node, path)?; + // The model keeps a player-variable chase under the global + // chase's name; the document names it by its own id. + let name = match (id, values.first()) { + ("chasePlayerVariableAtRate", Some(Value::PlayerVariable { .. })) => { + "chaseAtRate" + } + ("chasePlayerVariableOverTime", Some(Value::PlayerVariable { .. })) => { + "chaseOverTime" + } + ("chasePlayerVariableAtRate" | "chasePlayerVariableOverTime", _) => { + return Err(unrepresentable( + "a player-variable chase takes a playerVariable target", + &arg_path(0), + &args[0], + )); + } + ("chaseAtRate" | "chaseOverTime", Some(Value::PlayerVariable { .. })) => { + return Err(unrepresentable( + "a playerVariable chase is named chasePlayerVariable…", + path, + node, + )); + } + _ => id, + }; + Action::call(name, values) + } + }; + Ok(action) + } + + /// Check the argument count against a catalog signature, with the rule + /// canonical validation applies. + fn check_arity( + &self, + entry: &CatalogEntry, + values: &[Value], + node: &JsonValue, + path: &str, + ) -> Result<()> { + let folded = entry.folds_player_variable_pair() + && matches!(values.first(), Some(Value::PlayerVariable { .. })); + let supplied = values.len() + usize::from(folded); + if let Some((min, max)) = entry.rejected_arity(supplied) { + return Err(StatementContentError::ArgumentCount { + id: entry.id.clone(), + min, + max, + supplied, + path: path.to_string(), + node: node.clone(), + }); + } + Ok(()) + } + + fn variable(&self, node: &JsonValue, path: &str) -> Result { + let object = self.object(node, path)?; + match ( + object.get("variable").and_then(JsonValue::as_str), + object.len(), + ) { + (Some(name), 1) => Ok(name.to_string()), + _ => Err(unrepresentable( + "expected a {\"variable\"} node", + path, + node, + )), + } + } + + fn modify_op(&self, node: &JsonValue, path: &str) -> Result { + let object = self.object(node, path)?; + let (id, args) = self.call_parts(object, node, path)?; + self.only_keys(object, &["call", "args"], path, node)?; + if !args.is_empty() { + return Err(StatementContentError::ArgumentCount { + id: id.to_string(), + min: 0, + max: Some(0), + supplied: args.len(), + path: path.to_string(), + node: node.clone(), + }); + } + ModifyOp::from_catalog_id(id) + .ok_or_else(|| unrepresentable(format!("'{id}' is not a modify operation"), path, node)) + } + + fn value(&self, node: &JsonValue, path: &str) -> Result { + match node { + JsonValue::Number(number) => Ok(Value::Number(number.as_f64().ok_or_else(|| { + unrepresentable("number is not representable as f64", path, node) + })?)), + JsonValue::Bool(value) => Ok(Value::Bool(*value)), + JsonValue::Null => Ok(Value::Null), + JsonValue::Object(object) => self.object_value(object, node, path), + _ => Err(unrepresentable("expected a value node", path, node)), + } + } + + fn object_value( + &self, + object: &Map, + node: &JsonValue, + path: &str, + ) -> Result { + self.object(node, path)?; + let text = |key: &str| object.get(key).and_then(JsonValue::as_str); + if object.contains_key("call") { + self.only_keys(object, &["call", "args"], path, node)?; + return self.value_call(object, node, path); + } + let tagged = |keys: &[&str]| -> Result<()> { + if object.len() == keys.len() && keys.iter().all(|key| object.contains_key(*key)) { + Ok(()) + } else { + Err(unrepresentable("malformed tagged node", path, node)) + } + }; + if object.contains_key("string") { + tagged(&["string"])?; + let string = text("string") + .ok_or_else(|| unrepresentable("'string' must be text", path, node))?; + return Ok(Value::string(string)); + } + if object.contains_key("localizedString") { + tagged(&["localizedString"])?; + let id = text("localizedString") + .ok_or_else(|| unrepresentable("'localizedString' must be an id", path, node))?; + if !self.catalog.localized_strings().any(|entry| entry.id == id) { + return Err(StatementContentError::UnknownId { + id: id.to_string(), + path: path.to_string(), + node: node.clone(), + }); + } + return Ok(Value::LocalizedString(id.to_string())); + } + if object.contains_key("enum") { + tagged(&["enum", "member"])?; + let (Some(domain), Some(member)) = (text("enum"), text("member")) else { + return Err(unrepresentable("enum and member must be text", path, node)); + }; + let known = self + .catalog + .enum_domain(domain) + .is_some_and(|domain| domain.members.iter().any(|m| m.member == member)); + if !known { + return Err(StatementContentError::UnknownId { + id: format!("{domain}.{member}"), + path: path.to_string(), + node: node.clone(), + }); + } + return Ok(Value::Enum { + value_type: domain.to_string(), + value: member.to_string(), + }); + } + if object.contains_key("ambiguous") { + return Err(unrepresentable( + "an ambiguous enum member has no canonical identity to write", + path, + node, + )); + } + if object.contains_key("subroutine") { + tagged(&["subroutine"])?; + let name = text("subroutine") + .ok_or_else(|| unrepresentable("'subroutine' must be a name", path, node))?; + return Ok(Value::Subroutine(name.to_string())); + } + Err(unrepresentable("unknown node form", path, node)) + } + + fn value_call( + &self, + object: &Map, + node: &JsonValue, + path: &str, + ) -> Result { + let (id, args) = self.call_parts(object, node, path)?; + let arg_path = |index: usize| child(&child(path, "args"), index); + let count_error = |min: usize, max: Option| StatementContentError::ArgumentCount { + id: id.to_string(), + min, + max, + supplied: args.len(), + path: path.to_string(), + node: node.clone(), + }; + match id { + GLOBAL_VARIABLE_CALL => { + if args.len() != 1 { + return Err(count_error(1, Some(1))); + } + Ok(Value::GlobalVariable( + self.variable(&args[0], &arg_path(0))?, + )) + } + PLAYER_VARIABLE_CALL => { + if args.len() != 2 { + return Err(count_error(2, Some(2))); + } + Ok(Value::player_variable( + self.value(&args[0], &arg_path(0))?, + self.variable(&args[1], &arg_path(1))?, + )) + } + MEMBER_ACCESS_CALL => { + if !(2..=3).contains(&args.len()) { + return Err(count_error(2, Some(3))); + } + let values = self.values(args, path)?; + if !matches!(values[1], Value::String(_)) { + return Err(unrepresentable( + "memberAccess member must be a string", + &arg_path(1), + &args[1], + )); + } + Ok(Value::Call { + name: id.to_string(), + args: values, + }) + } + _ => { + let values = self.values(args, path)?; + // A modify operand (`add`, `appendToArray`, …) shares its id + // with a value of the same name; with no arguments it is the + // operand. + let operand = args.is_empty() && wir::is_operation_helper(id); + if !operand && let Some(entry) = self.catalog.entry(Kind::Value, id) { + self.check_arity(entry, &values, node, path)?; + return Ok(self.shaped_call(id, values)); + } + if self.catalog.entry(Kind::Operator, id).is_some() + || wir::is_canonical_helper_call(id, args.len()) + { + return Ok(Value::Call { + name: id.to_string(), + args: values, + }); + } + if wir::is_canonical_helper(id) { + return Err(count_error(0, Some(0))); + } + Err(StatementContentError::UnknownId { + id: id.to_string(), + path: path.to_string(), + node: node.clone(), + }) + } + } + } + + fn values(&self, args: &[JsonValue], path: &str) -> Result> { + args.iter() + .enumerate() + .map(|(index, arg)| self.value(arg, &child(&child(path, "args"), index))) + .collect() + } + + /// The model shape the parser gives a catalog value call: `array` and + /// `vector` have their own variants. + fn shaped_call(&self, id: &str, mut values: Vec) -> Value { + match (id, values.len()) { + ("eventPlayer", 0) => Value::EventPlayer, + ("array", _) => Value::Array(values), + ("vector", 3) => { + let z = Box::new(values.pop().expect("three values")); + let y = Box::new(values.pop().expect("three values")); + let x = Box::new(values.pop().expect("three values")); + Value::Vector { x, y, z } + } + _ => Value::call(id, values), + } + } +} diff --git a/crates/workshop-rs/src/values/arity.rs b/crates/workshop-rs/src/values/arity.rs new file mode 100644 index 0000000..3b49444 --- /dev/null +++ b/crates/workshop-rs/src/values/arity.rs @@ -0,0 +1,23 @@ +//! The argument-count rule shared by canonical validation and +//! `rule-content-v1` statement conversion. + +use crate::catalog::CatalogEntry; + +impl CatalogEntry { + /// The argument-count range this entry accepts for `supplied` arguments + /// (a folded player-variable pair counted as two), or `None` when + /// `supplied` is accepted. The range is `(min, max)` with `max` unset + /// for a variadic signature. + /// + /// An empty signature means the arity is not declared, not that the + /// builtin takes no arguments (documented variadic calls such as Custom + /// String), so every count is accepted. + pub(crate) fn rejected_arity(&self, supplied: usize) -> Option<(usize, Option)> { + if self.param_count() == 0 && self.required_param_count() == 0 { + return None; + } + let min = self.required_param_count(); + let max = (!self.is_variadic()).then_some(self.param_count()); + (supplied < min || max.is_some_and(|max| supplied > max)).then_some((min, max)) + } +} diff --git a/crates/workshop-rs/src/values/mod.rs b/crates/workshop-rs/src/values/mod.rs index 301eab1..583c3c7 100644 --- a/crates/workshop-rs/src/values/mod.rs +++ b/crates/workshop-rs/src/values/mod.rs @@ -4,6 +4,7 @@ //! The shared parser and output boundaries preserve their locale-independent //! identities. +mod arity; pub(crate) mod emitter; pub(crate) mod parser; pub(crate) mod validate; diff --git a/crates/workshop-rs/src/values/validate.rs b/crates/workshop-rs/src/values/validate.rs index 9fa7db4..ca6ccb9 100644 --- a/crates/workshop-rs/src/values/validate.rs +++ b/crates/workshop-rs/src/values/validate.rs @@ -199,25 +199,17 @@ pub(crate) fn validate_call_signature( program: &wir::Program, catalog: &Catalog, ) -> Result<()> { - // An empty signature in the current inventory means that arity is not - // declared, not that the builtin is a zero-argument function. This is - // important for documented variadic calls such as Custom String. - if entry.param_count() == 0 && entry.required_param_count() == 0 { - return Ok(()); - } let player_pair_folded = entry.wir_args_folded(program, args); let supplied = args.len() + usize::from(player_pair_folded); - if supplied < entry.required_param_count() - || (!entry.is_variadic() && supplied > entry.param_count()) - { + if let Some((min, max)) = entry.rejected_arity(supplied) { return Err(WorkshopError::unsupported( format!( "{} '{}' expects {}..{}{} argument(s), got {}", entry.kind.as_str(), entry.id, - entry.required_param_count(), + min, entry.param_count(), - if entry.is_variadic() { "+" } else { "" }, + if max.is_none() { "+" } else { "" }, supplied ), span, diff --git a/crates/workshop-rs/tests/integration.rs b/crates/workshop-rs/tests/integration.rs index 65b47c6..4b204e4 100644 --- a/crates/workshop-rs/tests/integration.rs +++ b/crates/workshop-rs/tests/integration.rs @@ -40,3 +40,5 @@ mod public_api; mod real_projects; #[path = "rule_content.rs"] mod rule_content; +#[path = "statement_from_content.rs"] +mod statement_from_content; diff --git a/crates/workshop-rs/tests/statement_from_content.rs b/crates/workshop-rs/tests/statement_from_content.rs new file mode 100644 index 0000000..27772e2 --- /dev/null +++ b/crates/workshop-rs/tests/statement_from_content.rs @@ -0,0 +1,325 @@ +//! Contract tests for `rules::statement::statement_from_content`, the input +//! direction of `workshop-rs/rule-content-v1` (workshop-rs#531). + +use serde_json::{Value as JsonValue, json}; +use workshop_rs::rules::content::rule_content; +use workshop_rs::rules::statement::{Statement, StatementContentError, statement_from_content}; +use workshop_rs::{Event, Rule, emitter, parser}; + +use crate::common::{catalog, corpus_text, fixture, locale}; + +fn convert(node: &JsonValue) -> Result { + statement_from_content(&catalog(), node) +} + +/// The statement's content as the export renders it inside a rule. +fn content_of(statement: Statement) -> JsonValue { + let rule = Rule::new("statement", Event::Global); + match statement { + Statement::Condition(condition) => { + let content = rule_content(&rule.condition(condition)).expect("content renders"); + content["conditions"][0].clone() + } + Statement::Action(action) => { + let content = rule_content(&rule.action(action)).expect("content renders"); + content["actions"][0].clone() + } + _ => unreachable!("only conditions and actions exist"), + } +} + +fn statements(content: &JsonValue) -> impl Iterator { + ["conditions", "actions"] + .into_iter() + .flat_map(|list| content[list].as_array().expect("statement list")) +} + +fn has_ambiguous(node: &JsonValue) -> bool { + match node { + JsonValue::Object(map) => map.contains_key("ambiguous") || map.values().any(has_ambiguous), + JsonValue::Array(items) => items.iter().any(has_ambiguous), + _ => false, + } +} + +#[derive(Default)] +struct Tally { + converted: usize, + refused_ambiguous: usize, + locales: std::collections::BTreeSet, +} + +/// Round-trip every statement of `source` in every catalog locale. +fn assert_round_trip(source: &str, source_locale: &str, tally: &mut Tally) { + let catalog = catalog(); + let program = parser::parse(source, &catalog, &locale(source_locale)).expect("source parses"); + for target in catalog.locales() { + // A locale without every spelling the program needs cannot render + // it; the statement content does not depend on the locale otherwise. + let Ok(text) = emitter::emit(&program, &catalog, target) else { + continue; + }; + tally.locales.insert(target.to_string()); + let reparsed = parser::parse(&text, &catalog, target) + .unwrap_or_else(|error| panic!("{target} reparse: {error}")); + for rule in &reparsed.rules { + let content = rule_content(rule).expect("rule content renders"); + for node in statements(&content) { + if has_ambiguous(node) { + let error = statement_from_content(&catalog, node) + .expect_err("an ambiguous enum member is refused"); + assert!( + matches!(error, StatementContentError::Unrepresentable { .. }), + "{error:?}" + ); + tally.refused_ambiguous += 1; + continue; + } + let statement = statement_from_content(&catalog, node).unwrap_or_else(|error| { + panic!("{target} rule '{}': {error}\n{node}", rule.name) + }); + assert_eq!( + &content_of(statement), + node, + "{target} rule '{}'", + rule.name + ); + tally.converted += 1; + } + } + } +} + +#[test] +fn practice_range_statements_round_trip_in_every_locale() { + let mut tally = Tally::default(); + for (tag, file) in [("en-US", "en-US.ow"), ("zh-CN", "zh-CN.ow")] { + let source = std::fs::read_to_string(fixture("practice-range", file)).expect("fixture"); + assert_round_trip(&source, tag, &mut tally); + } + assert!(tally.converted > 1000, "converted {}", tally.converted); + assert!(tally.refused_ambiguous > 0); + // The locales with complete spellings for the corpus; emission skips + // any locale that lacks a mapping the program needs. + assert!(tally.locales.contains("en-us") && tally.locales.contains("zh-cn")); +} + +#[test] +fn corpus_statements_round_trip_in_every_locale() { + let mut tally = Tally::default(); + for id in [ + "basic-rule", + "control-flow", + "declarations-rules", + "expressions-values", + "receiver-calls", + ] { + assert_round_trip(&corpus_text(id), "en-US", &mut tally); + } + assert!(tally.converted > 0); +} + +fn error_of(node: JsonValue) -> StatementContentError { + convert(&node).expect_err("the node is refused") +} + +#[test] +fn unknown_id_names_the_node() { + let node = json!({"call": "wait", "args": [1.0, {"call": "noSuchValue", "args": []}]}); + let error = error_of(node); + assert!(matches!(&error, StatementContentError::UnknownId { id, .. } if id == "noSuchValue")); + assert_eq!(error.path(), "/args/1"); + assert_eq!(error.node(), &json!({"call": "noSuchValue", "args": []})); + let error = error_of(json!({"call": "noSuchAction", "args": []})); + assert!(matches!(error, StatementContentError::UnknownId { .. })); +} + +#[test] +fn wrong_argument_count_is_refused() { + for node in [ + json!({"call": "wait", "args": []}), + json!({"call": "wait", "args": [1.0, 2.0, 3.0]}), + json!({"call": "end", "args": [1.0]}), + json!({"call": "if", "args": []}), + json!({"value": {"call": "vector", "args": [1.0]}}), + json!({"value": {"call": "globalVariable", "args": []}}), + ] { + let error = error_of(node.clone()); + assert!( + matches!(error, StatementContentError::ArgumentCount { .. }), + "{node}: {error:?}" + ); + } +} + +#[test] +fn internal_sentinel_is_refused_as_action_and_as_value() { + let sentinel = json!({"call": "__ambiguous_enum", "args": []}); + for node in [sentinel.clone(), json!({"value": sentinel})] { + let error = error_of(node); + assert!( + matches!(&error, StatementContentError::InternalSentinel { id, .. } if id == "__ambiguous_enum"), + "{error:?}" + ); + } +} + +#[test] +fn nodes_that_form_no_statement_are_refused() { + for node in [ + json!(1.0), + json!({}), + json!({"value": 1.0, "call": "end", "args": []}), + json!({"call": "end"}), + json!({"call": "end", "args": [], "disabled": false}), + json!({"call": "end", "args": [], "extra": 1}), + json!({"call": "rawWorkshopAction", "args": []}), + json!({"value": {"variable": "x"}}), + json!({"value": "text"}), + json!({"value": {"enum": "NoDomain", "member": "X"}}), + json!({"call": "setGlobalVariable", "args": [1.0, 2.0]}), + json!({"call": "modifyGlobalVariable", "args": [{"variable": "x"}, {"call": "eventPlayer", "args": []}, 1.0]}), + ] { + let error = convert(&node).expect_err("refused"); + assert!( + matches!( + error, + StatementContentError::Unrepresentable { .. } + | StatementContentError::UnknownId { .. } + ), + "{node}: {error:?}" + ); + } + let error = error_of(json!({"value": {"variable": "x"}})); + assert!(matches!( + error, + StatementContentError::Unrepresentable { .. } + )); +} + +#[test] +fn unresolved_ambiguous_enum_is_refused_not_approximated() { + let node = json!({"value": {"call": "compare", "args": [ + 1.0, + {"call": "==", "args": []}, + {"ambiguous": "Up", "candidates": [ + {"enum": "Vector", "member": "UP"}, {"enum": "Rounding", "member": "UP"} + ]} + ]}}); + let error = error_of(node); + assert!(matches!( + error, + StatementContentError::Unrepresentable { .. } + )); + assert_eq!(error.path(), "/value/args/2"); +} + +#[test] +fn spans_are_refused_wherever_they_appear() { + let span = json!({"start": {"line": 1, "column": 1}}); + for (node, path) in [ + (json!({"call": "end", "args": [], "span": span}), ""), + (json!({"value": true, "span": span}), ""), + ( + json!({"call": "if", "args": [{"call": "eventPlayer", "args": [], "span": span}]}), + "/args/0", + ), + ] { + let error = error_of(node); + assert!( + matches!(error, StatementContentError::Spans { .. }), + "{error:?}" + ); + assert_eq!(error.path(), path); + } +} + +#[test] +fn a_refusal_produces_no_model_value() { + // The result type is `Result`: an `Err` carries no statement. A partly + // valid statement is refused as a whole. + let node = json!({"call": "wait", "args": [1.0, {"call": "noSuchValue", "args": []}]}); + assert!(convert(&node).is_err()); +} + +#[test] +fn disabled_statements_keep_their_modifier() { + for node in [ + json!({"call": "abort", "args": [], "disabled": true}), + json!({"value": true, "disabled": true}), + ] { + assert_eq!(content_of(convert(&node).expect("converts")), node); + } +} + +/// Conversion and canonical validation share one argument-count rule +/// (AGENTS.md "Sibling paths share checks"): for a folded player-variable +/// pair, a variadic entry and an undeclared-signature entry, both accept and +/// reject the same argument counts. +#[test] +fn conversion_and_validation_agree_on_argument_counts() { + use workshop_rs::catalog::Kind; + use workshop_rs::{Action, Condition, Program, Value, Variable}; + + let catalog = catalog(); + let all = || { + catalog + .entries_of(Kind::Action) + .chain(catalog.entries_of(Kind::Value)) + }; + let folded = all() + .find(|e| e.param_type(0) == Some("Player|Array") && e.param_type(1) == Some("Variable")) + .expect("a folded player-variable entry"); + let variadic = all().find(|e| e.is_variadic()).expect("a variadic entry"); + let undeclared = all() + .find(|e| e.param_count() == 0 && e.required_param_count() == 0) + .expect("an undeclared-signature entry"); + + let player_target = json!({"call": "playerVariable", "args": [ + {"call": "eventPlayer", "args": []}, {"variable": "v"} + ]}); + for (entry, fold) in [(folded, true), (variadic, false), (undeclared, false)] { + for count in 0..=entry.param_count() + 3 { + let (mut args, mut values) = (Vec::new(), Vec::new()); + for index in 0..count { + if fold && index == 0 { + args.push(player_target.clone()); + values.push(Value::player_variable(Value::EventPlayer, "v")); + } else { + args.push(json!(0.0)); + values.push(Value::from(0.0)); + } + } + let call = json!({"call": entry.id, "args": args}); + let (node, rule) = if entry.kind == Kind::Action { + ( + call, + Rule::new("agreement", Event::Global) + .action(Action::call(entry.id.clone(), values)), + ) + } else { + ( + json!({"value": call}), + Rule::new("agreement", Event::Global) + .condition(Condition::new(Value::call(entry.id.clone(), values))), + ) + }; + let converted = statement_from_content(&catalog, &node); + let converter_rejects = + matches!(converted, Err(StatementContentError::ArgumentCount { .. })); + + let mut program = Program::new(); + program.player_variable(Variable::new("v")); + program.rule(rule); + let validator_rejects = + workshop_rs::validate::validate_canonical_ids(&program, &catalog) + .is_err_and(|error| error.to_string().contains("argument(s)")); + + assert_eq!( + converter_rejects, validator_rejects, + "'{}' with {count} argument(s)", + entry.id + ); + } + } +} diff --git a/docs/compatibility-facades.md b/docs/compatibility-facades.md index 1ef0947..a325c17 100644 --- a/docs/compatibility-facades.md +++ b/docs/compatibility-facades.md @@ -81,6 +81,17 @@ The document shape itself is versioned by its format id, not by Rust API stability: an incompatible content change needs a new format id, and the JSON output is a data contract consumers may hold across versions. +`rules::statement` is the input direction of the same format. +`statement_from_content(catalog, node)` converts one condition +(`{"value": node}`) or action (`{"call": id, "args": [...]}`) list item, with +optional `"disabled": true`, into a `Statement`. A node read from +`Rule::content` is valid input unchanged, and the format id and schema do not +change. The call refuses, with a distinct `StatementContentError` that names +the node by JSON pointer, an unknown id, a wrong argument count, an internal +sentinel, a node that forms no statement (including a preserved ambiguous enum +member and an opaque raw action), and a node carrying spans, which are +output-only. It produces no model value on error. + ## Catalog-backed actions and values Catalog actions and values are built with `Action::call` and `Value::call`,