diff --git a/Cargo.lock b/Cargo.lock index e2978e5..a55e165 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -86,6 +86,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "basic-toml" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f2139706359229bfa8f19142ac1155b4b80beafb7a60471ac5dd109d4a19778" +dependencies = [ + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -199,6 +208,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.14.2" @@ -244,6 +259,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.65" @@ -383,10 +404,12 @@ name = "rust_slim" version = "0.2.2" dependencies = [ "anyhow", + "chrono", "convert_case", "rust_slim_macros", "slim_protocol", "thiserror", + "trybuild", ] [[package]] @@ -399,6 +422,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "serde" version = "1.0.193" @@ -419,6 +448,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0652c533506ad7a2e353cce269330d6afd8bdfb6d75e0ace5b35aacbd7b9e9" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.4" @@ -470,6 +510,15 @@ dependencies = [ "ulid", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.50" @@ -524,6 +573,21 @@ dependencies = [ "winnow", ] +[[package]] +name = "trybuild" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419ecd263363827c5730386f418715766f584e2f874d32c23c5b00bd9727e7e" +dependencies = [ + "basic-toml", + "glob", + "once_cell", + "serde", + "serde_derive", + "serde_json", + "termcolor", +] + [[package]] name = "ulid" version = "1.1.0" @@ -617,6 +681,15 @@ version = "0.2.88" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d046c5d029ba91a1ed14da14dca44b68bf2f124cfbaf741c54151fdb3e0750b" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.51.1" diff --git a/rust_slim/Cargo.toml b/rust_slim/Cargo.toml index fd03788..b867a07 100644 --- a/rust_slim/Cargo.toml +++ b/rust_slim/Cargo.toml @@ -12,6 +12,7 @@ readme = "readme.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = { version = "0.4", default-features = false, features = ["std"] } slim_protocol = { path = "../slim_protocol" } convert_case = "0.6" rust_slim_macros = { path = "../rust_slim_macros", optional = true } @@ -19,6 +20,7 @@ thiserror = "1" [dev-dependencies] anyhow = "1" +trybuild = "1" [features] macros = ["dep:rust_slim_macros"] diff --git a/rust_slim/readme.md b/rust_slim/readme.md index 6707eb4..8539e3b 100644 --- a/rust_slim/readme.md +++ b/rust_slim/readme.md @@ -11,4 +11,13 @@ It does not support using the STOUD and STDIN for comunication. This is currently in an unstable version. The general API can change in the next versions. -For more details, take a look at the [documentation](https://docs.rs/rust_slim/latest/rust_slim/) \ No newline at end of file +For more details, take a look at the [documentation](https://docs.rs/rust_slim/latest/rust_slim/) + +## Migrating manually implemented fixtures + +`SlimFixture::execute_method` now receives `Vec` and returns +`Result`. Use `FromSlimValue` to convert each +argument and `IntoSlimValue` to convert the method result. These conversions +preserve protocol lists, null, void, and in-process object handles. The old +`ToSlimResultString` trait remains available as a string conversion helper, +but it does not preserve those structured values. diff --git a/rust_slim/src/lib.rs b/rust_slim/src/lib.rs index 444eb87..662dd57 100644 --- a/rust_slim/src/lib.rs +++ b/rust_slim/src/lib.rs @@ -119,10 +119,12 @@ pub use server::SlimServer; use std::fmt::{Display, Formatter}; pub use to_slim_result_string::*; pub use utils::from_rust_module_path_to_class_path; +pub use value::{FromSlimValue, IntoSlimValue, SlimObject, SlimValue}; mod server; mod to_slim_result_string; mod utils; +mod value; /// Fixtures must implement this trait to be able to be executed by the slim server. /// The `#[fixture]` macro will automatically implement it for the type in the impl block. @@ -132,8 +134,8 @@ pub trait SlimFixture { fn execute_method( &mut self, method: &str, - args: Vec, - ) -> Result; + args: Vec, + ) -> Result; } /// ClassPath that will be used in the construction of the fixture. @@ -155,14 +157,14 @@ pub trait ClassPath { /// Trait used to construct the fixture. It is auto-implemented for fixtures that also implement Default. pub trait Constructor { - fn construct(args: Vec) -> Self; + fn construct(args: Vec) -> Self; } impl Constructor for T where T: Default, { - fn construct(_args: Vec) -> T { + fn construct(_args: Vec) -> T { T::default() } } @@ -172,12 +174,24 @@ where pub enum ExecuteMethodError { /// The method might not exists, which should cause a MethodNotFound error. MethodNotFound { method: String, class: String }, - /// We might have an issue parsing the arguments. The implementation made by the `#[fixture]` macro tries to parse each argument using the [FromStr](https://doc.rust-lang.org/std/str/trait.FromStr.html) trait. + /// A fixture argument could not be converted through [`FromSlimValue`]. ArgumentParsingError(String), /// And there might be some failure in the method itself, which should cause an ExecutionError. ExecutionError(String), } +impl ExecuteMethodError { + /// Adds the zero-based fixture argument position to a conversion error. + pub fn for_argument(self, index: usize) -> Self { + match self { + Self::ArgumentParsingError(message) => { + Self::ArgumentParsingError(format!("{}: {message}", index + 1)) + } + error => error, + } + } +} + impl Display for ExecuteMethodError { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { diff --git a/rust_slim/src/server.rs b/rust_slim/src/server.rs index b4e0632..04f92c6 100644 --- a/rust_slim/src/server.rs +++ b/rust_slim/src/server.rs @@ -1,8 +1,8 @@ -use crate::{ClassPath, Constructor, SlimFixture}; +use crate::{ClassPath, Constructor, SlimFixture, SlimValue}; use convert_case::{Case, Casing}; use slim_protocol::{ ByeOrSlimInstructions, ExceptionMessage, FromSlimReader, FromSlimReaderError, Instruction, - InstructionResult, InstructionResultValue, ToSlimString, + InstructionResult, InstructionResultValue, SlimValue as WireSlimValue, ToSlimString, }; use std::{ collections::HashMap, @@ -19,14 +19,14 @@ pub enum SlimServerError { FromSlimReaderError(#[from] FromSlimReaderError), } -pub type SlimClosureConstructor = Box) -> Box>; +pub type SlimClosureConstructor = Box) -> Box>; /// The SlimServer responsible to get the Slim commands and execute against the Fixtures. pub struct SlimServer { fixtures: HashMap, instances: HashMap>, libraries: HashMap>, - symbols: HashMap, + symbols: HashMap, imports: Vec, reader: BufReader, writer: W, @@ -50,8 +50,8 @@ impl SlimServer { pub fn add_fixture(&mut self) { self.fixtures.insert( T::class_path(), - Box::new(|args: Vec| Box::new(T::construct(args)) as Box) - as Box) -> Box>, + Box::new(|args: Vec| Box::new(T::construct(args)) as Box) + as Box) -> Box>, ); } @@ -101,7 +101,7 @@ impl SlimServer { )); continue; }; - let args = self.parse_symbols(args); + let args = self.parse_wire_values(args); if instance.starts_with("library") { self.libraries.insert(instance, fixture(args)); } else { @@ -115,7 +115,7 @@ impl SlimServer { function, args, } => { - let args = self.parse_symbols(args); + let args = self.parse_wire_values(args); let instances = if instance.starts_with("library") { &mut self.libraries } else { @@ -131,22 +131,7 @@ impl SlimServer { let function = function.to_case(Case::Snake); match instance.execute_method(&function, args) { - Ok(value) if value == "/__VOID__/" => { - results.push(InstructionResult::void(id)) - } - Ok(value) if value.starts_with("/__ARRAY[") && value.ends_with("]__/") => { - let value = value - .trim_start_matches("/__ARRAY[") - .trim_end_matches("]__/"); - results.push(InstructionResult::list( - id, - value - .split("__|__") - .map(|value| InstructionResultValue::String(value.into())) - .collect(), - )) - } - Ok(value) => results.push(InstructionResult::string(id, value)), + Ok(value) => results.push(instruction_result_for_value(id, value)), Err(error) => results.push(InstructionResult::exception( id, ExceptionMessage::new(error.to_string()), @@ -160,7 +145,7 @@ impl SlimServer { function, args, } => { - let args = self.parse_symbols(args); + let args = self.parse_wire_values(args); let instances = if instance.starts_with("library") { &mut self.libraries } else { @@ -176,12 +161,8 @@ impl SlimServer { let function = function.to_case(Case::Snake); let symbol = symbol.strip_prefix('$').unwrap_or(&symbol).into(); match instance.execute_method(&function, args) { - Ok(value) if value == "/__VOID__/" => { - results.push(InstructionResult::void(id)); - self.symbols.insert(symbol, "".into()); - } Ok(value) => { - results.push(InstructionResult::string(id, value.clone())); + results.push(instruction_result_for_value(id, value.clone())); self.symbols.insert(symbol, value); } Err(error) => results.push(InstructionResult::exception( @@ -192,6 +173,7 @@ impl SlimServer { } Instruction::Assign { id, symbol, value } => { let symbol = symbol.strip_prefix('$').unwrap_or(&symbol).into(); + let value = self.parse_wire_value(value); self.symbols.insert(symbol, value); results.push(InstructionResult::ok(id)) } @@ -213,21 +195,44 @@ impl SlimServer { None } - fn parse_symbols(&self, args: Vec) -> Vec { - args.into_iter().map(|arg| self.parse_symbol(arg)).collect() + fn parse_wire_values(&self, args: Vec) -> Vec { + args.into_iter() + .map(|arg| self.parse_wire_value(arg)) + .collect() + } + + fn parse_wire_value(&self, value: WireSlimValue) -> SlimValue { + match value { + WireSlimValue::String(value) if value == "null" => SlimValue::Null, + WireSlimValue::String(value) => SlimValue::String(self.parse_symbol(value)), + WireSlimValue::List(values) => SlimValue::List( + values + .into_iter() + .map(|value| self.parse_wire_value(value)) + .collect(), + ), + } } fn parse_symbol(&self, mut value: String) -> String { while let Some((before, after)) = value.split_once('$') { if let Some((name, rest)) = after.split_once(' ') { let mut new_value = String::from(before); - new_value += self.symbols.get(name).unwrap_or(&String::new()); + new_value += &self + .symbols + .get(name) + .map(SlimValue::as_text) + .unwrap_or_default(); new_value += " "; new_value += rest; value = new_value; } else { let mut new_value = String::from(before); - new_value += self.symbols.get(after).unwrap_or(&String::new()); + new_value += &self + .symbols + .get(after) + .map(SlimValue::as_text) + .unwrap_or_default(); value = new_value; } } @@ -235,6 +240,25 @@ impl SlimServer { } } +fn instruction_result_for_value(id: slim_protocol::Id, value: SlimValue) -> InstructionResult { + match value { + SlimValue::Void => InstructionResult::void(id), + value => InstructionResult::new(id, instruction_result_value(value)), + } +} + +fn instruction_result_value(value: SlimValue) -> InstructionResultValue { + match value { + SlimValue::String(value) => InstructionResultValue::String(value), + SlimValue::List(values) => { + InstructionResultValue::List(values.into_iter().map(instruction_result_value).collect()) + } + SlimValue::Null => InstructionResultValue::String("null".into()), + SlimValue::Void => InstructionResultValue::Void, + SlimValue::Object(value) => InstructionResultValue::String(value.display_value().into()), + } +} + #[cfg(test)] mod tests { use slim_protocol::{FromSlimReader, Id, ToSlimString}; @@ -430,6 +454,62 @@ mod tests { Ok(()) } + #[test] + fn calls_preserve_nested_lists_and_call_and_assign_keeps_the_typed_value( + ) -> Result<(), Box> { + let reader = Cursor::new(Vec::new()); + let writer = Cursor::new(Vec::new()); + let mut slim_server = SlimServer::new(reader, writer); + slim_server.add_fixture::(); + let nested = WireSlimValue::List(vec![ + WireSlimValue::String("one".into()), + WireSlimValue::List(vec![WireSlimValue::String("two".into())]), + ]); + let result = slim_server.execute_instructions(vec![ + Instruction::Make { + id: Id::from("make"), + instance: "fixture".into(), + class: "Test.TestFixture".into(), + args: Vec::new(), + }, + Instruction::CallAndAssign { + id: Id::from("call"), + symbol: "$nested".into(), + instance: "fixture".into(), + function: "nested".into(), + args: vec![nested], + }, + Instruction::Call { + id: Id::from("embedded"), + instance: "fixture".into(), + function: "echo".into(), + args: vec!["prefix $nested suffix".into()], + }, + ]); + + assert_eq!( + InstructionResult::list( + Id::from("call"), + vec![InstructionResultValue::List(vec![ + InstructionResultValue::String("one".into()), + InstructionResultValue::List(vec![InstructionResultValue::String( + "two".into() + )]), + ])], + ), + result[1] + ); + assert!(matches!( + slim_server.symbols.get("nested"), + Some(SlimValue::List(_)) + )); + assert_eq!( + InstructionResult::string(Id::from("embedded"), "prefix [[one, [two]]] suffix".into()), + result[2] + ); + Ok(()) + } + #[test] fn execute_assign() -> Result<(), Box> { let mut vec = Vec::new(); @@ -587,11 +667,9 @@ mod tests { ); slim_server.imports.push("Namespace".into()); - let mut result = slim_server - .find_fixture("ExamplePathFixutre".into()) - .unwrap()(Vec::new()); + let mut result = slim_server.find_fixture("ExamplePathFixutre").unwrap()(Vec::new()); assert_eq!( - Ok("First".to_string()), + Ok(SlimValue::String("First".to_string())), result.execute_method("", Vec::new()) ); } @@ -616,11 +694,9 @@ mod tests { slim_server.imports.push("Namespace2".into()); slim_server.imports.push("Namespace1".into()); - let mut result = slim_server - .find_fixture("ExamplePathFixutre".into()) - .unwrap()(Vec::new()); + let mut result = slim_server.find_fixture("ExamplePathFixutre").unwrap()(Vec::new()); assert_eq!( - Ok("Second".to_string()), + Ok(SlimValue::String("Second".to_string())), result.execute_method("", Vec::new()) ); } @@ -628,30 +704,37 @@ mod tests { fn add_test_fixture_with_path( server: &mut SlimServer, class_path: impl Into, - return_value: Result, + return_value: Result, ) { server.fixtures.insert( class_path.into(), - Box::new(move |_: Vec| { + Box::new(move |_: Vec| { Box::new(TestFixture { return_value: return_value.clone(), }) as Box - }) as Box) -> Box>, + }) as Box) -> Box>, ); } struct TestFixture { - return_value: Result, + return_value: Result, } impl SlimFixture for TestFixture { fn execute_method( &mut self, method: &str, - parms: Vec, - ) -> Result { + parms: Vec, + ) -> Result { match method { - "echo" => Ok(parms.join(",")), + "echo" => Ok(SlimValue::String( + parms + .into_iter() + .map(|value| value.as_text()) + .collect::>() + .join(","), + )), + "nested" => Ok(SlimValue::List(parms)), _ => self.return_value.clone(), } } @@ -664,9 +747,9 @@ mod tests { } impl Constructor for TestFixture { - fn construct(_: Vec) -> Self { + fn construct(_: Vec) -> Self { Self { - return_value: Ok("Value".to_string()), + return_value: Ok(SlimValue::String("Value".to_string())), } } } diff --git a/rust_slim/src/to_slim_result_string.rs b/rust_slim/src/to_slim_result_string.rs index 6971e87..985f4da 100644 --- a/rust_slim/src/to_slim_result_string.rs +++ b/rust_slim/src/to_slim_result_string.rs @@ -1,7 +1,9 @@ use crate::ExecuteMethodError; -/// Converts the result of a method into a result that the SlimServer can handle. This is mainly used so you can return whatever you want in a method and we can convert it inside the macro expansion of the `[fixture]` macro. If you are implementating the [SlimFixture](crate::SlimFixture) manually, you can ignore this. -/// It has implementations for most basic types. +/// Legacy string-result conversion retained as a migration utility. +/// +/// New fixtures should implement [`crate::IntoSlimValue`] or use `#[fixture]`, +/// which preserves lists, null, and void without sentinel strings. pub trait ToSlimResultString { fn to_slim_result_string(self) -> Result; } diff --git a/rust_slim/src/value.rs b/rust_slim/src/value.rs new file mode 100644 index 0000000..6f9363f --- /dev/null +++ b/rust_slim/src/value.rs @@ -0,0 +1,450 @@ +//! Values passed between the SliM executor and Rust fixtures. +//! +//! `SlimValue` deliberately differs from `slim_protocol::SlimValue`: the +//! protocol only has strings and lists, while fixture execution also needs +//! null, void, and values that stay in-process as object symbols. + +use crate::ExecuteMethodError; +use chrono::NaiveDate; +use std::{any::Any, cell::RefCell, fmt, rc::Rc}; + +enum SlimObjectValue { + Opaque(Rc), + Fixture(Rc>), +} + +impl Clone for SlimObjectValue { + fn clone(&self) -> Self { + match self { + Self::Opaque(value) => Self::Opaque(Rc::clone(value)), + Self::Fixture(value) => Self::Fixture(Rc::clone(value)), + } + } +} + +/// An opaque, cloneable object kept by a single-threaded SliM server. +/// +/// The handle uses `Rc`, rather than `Arc`, because fixtures are executed by +/// one server thread. `display` is used only when an object must temporarily +/// be represented as text; future symbol dispatch can retain the handle. +#[derive(Clone)] +pub struct SlimObject { + value: SlimObjectValue, + display: String, +} + +impl SlimObject { + pub fn new(value: T, display: impl Into) -> Self { + Self { + value: SlimObjectValue::Opaque(Rc::new(value)), + display: display.into(), + } + } + + /// Creates an object that can later be copied into the server's fixture + /// registry by the SliM `make` fixture-chaining behavior. + pub fn fixture(value: T, display: impl Into) -> Self { + Self { + value: SlimObjectValue::Fixture(Rc::new(RefCell::new(value))), + display: display.into(), + } + } + + pub fn downcast_ref(&self) -> Option<&T> { + match &self.value { + SlimObjectValue::Opaque(value) => value.downcast_ref(), + SlimObjectValue::Fixture(_) => None, + } + } + + pub fn downcast(self) -> Result, Self> { + let Self { value, display } = self; + match value { + SlimObjectValue::Opaque(value) => value.downcast::().map_err(|value| Self { + value: SlimObjectValue::Opaque(value), + display, + }), + value @ SlimObjectValue::Fixture(_) => Err(Self { value, display }), + } + } + + pub fn as_fixture(&self) -> Option>> { + match &self.value { + SlimObjectValue::Opaque(_) => None, + SlimObjectValue::Fixture(value) => Some(Rc::clone(value)), + } + } + + pub fn display_value(&self) -> &str { + &self.display + } +} + +impl fmt::Debug for SlimObject { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("SlimObject").field(&self.display).finish() + } +} + +/// The typed value model exposed to fixture implementations. +#[derive(Clone, Debug)] +pub enum SlimValue { + String(String), + List(Vec), + Null, + Void, + Object(SlimObject), +} + +impl PartialEq for SlimValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::String(left), Self::String(right)) => left == right, + (Self::List(left), Self::List(right)) => left == right, + (Self::Null, Self::Null) | (Self::Void, Self::Void) => true, + (Self::Object(left), Self::Object(right)) => match (&left.value, &right.value) { + (SlimObjectValue::Opaque(left), SlimObjectValue::Opaque(right)) => { + Rc::ptr_eq(left, right) + } + (SlimObjectValue::Fixture(left), SlimObjectValue::Fixture(right)) => { + Rc::ptr_eq(left, right) + } + _ => false, + }, + _ => false, + } + } +} + +impl Eq for SlimValue {} + +impl From for SlimValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for SlimValue { + fn from(value: &str) -> Self { + Self::String(value.into()) + } +} + +impl SlimValue { + pub fn object(value: T, display: impl Into) -> Self { + Self::Object(SlimObject::new(value, display)) + } + + /// Lossy textual form used by the compatibility symbol replacement path. + pub fn as_text(&self) -> String { + match self { + Self::String(value) => value.clone(), + Self::List(values) => format!( + "[{}]", + values + .iter() + .map(Self::as_text) + .collect::>() + .join(", ") + ), + Self::Null => "null".into(), + Self::Void => String::new(), + Self::Object(value) => value.display_value().into(), + } + } +} + +/// Converts one fixture argument from its runtime representation. +/// +/// Implement this trait for application-specific argument types to use them +/// in a `#[fixture]` method. +pub trait FromSlimValue: Sized { + fn from_slim_value(value: SlimValue) -> Result; +} + +/// Converts a fixture return value into a runtime value. +/// +/// Application types can implement this trait directly without depending on +/// the wire protocol representation. +pub trait IntoSlimValue { + fn into_slim_value(self) -> Result; +} + +impl FromSlimValue for SlimValue { + fn from_slim_value(value: SlimValue) -> Result { + Ok(value) + } +} + +impl IntoSlimValue for SlimValue { + fn into_slim_value(self) -> Result { + Ok(self) + } +} + +impl FromSlimValue for SlimObject { + fn from_slim_value(value: SlimValue) -> Result { + match value { + SlimValue::Object(value) => Ok(value), + other => Err(conversion_error("object", &other)), + } + } +} + +impl IntoSlimValue for SlimObject { + fn into_slim_value(self) -> Result { + Ok(SlimValue::Object(self)) + } +} + +impl FromSlimValue for String { + fn from_slim_value(value: SlimValue) -> Result { + match value { + SlimValue::String(value) => Ok(value), + other => Err(conversion_error("String", &other)), + } + } +} + +impl IntoSlimValue for String { + fn into_slim_value(self) -> Result { + Ok(SlimValue::String(self)) + } +} + +impl IntoSlimValue for &str { + fn into_slim_value(self) -> Result { + Ok(SlimValue::String(self.into())) + } +} + +macro_rules! scalar_values { + ($($ty:ty),* $(,)?) => {$( + impl FromSlimValue for $ty { + fn from_slim_value(value: SlimValue) -> Result { + let SlimValue::String(value) = value else { + return Err(ExecuteMethodError::ArgumentParsingError( + format!("expected a string for {}", stringify!($ty)), + )); + }; + value.parse::<$ty>().map_err(|error| { + ExecuteMethodError::ArgumentParsingError(error.to_string()) + }) + } + } + + impl IntoSlimValue for $ty { + fn into_slim_value(self) -> Result { + Ok(SlimValue::String(self.to_string())) + } + } + )*}; +} + +scalar_values!(bool, u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64); + +impl FromSlimValue for NaiveDate { + fn from_slim_value(value: SlimValue) -> Result { + let SlimValue::String(value) = value else { + return Err(ExecuteMethodError::ArgumentParsingError( + "expected a date string in dd-MMM-yyyy form".into(), + )); + }; + Self::parse_from_str(&value, "%d-%b-%Y") + .map_err(|error| ExecuteMethodError::ArgumentParsingError(error.to_string())) + } +} + +impl IntoSlimValue for NaiveDate { + fn into_slim_value(self) -> Result { + Ok(SlimValue::String(self.format("%d-%b-%Y").to_string())) + } +} + +impl FromSlimValue for Option +where + T: FromSlimValue, +{ + fn from_slim_value(value: SlimValue) -> Result { + match value { + SlimValue::Null => Ok(None), + value => T::from_slim_value(value).map(Some), + } + } +} + +impl IntoSlimValue for Option +where + T: IntoSlimValue, +{ + fn into_slim_value(self) -> Result { + match self { + Some(value) => value.into_slim_value(), + None => Ok(SlimValue::Null), + } + } +} + +impl FromSlimValue for Vec +where + T: FromSlimValue, +{ + fn from_slim_value(value: SlimValue) -> Result { + let SlimValue::List(values) = value else { + return Err(ExecuteMethodError::ArgumentParsingError( + "expected a list".into(), + )); + }; + values.into_iter().map(T::from_slim_value).collect() + } +} + +impl IntoSlimValue for Vec +where + T: IntoSlimValue, +{ + fn into_slim_value(self) -> Result { + self.into_iter() + .map(T::into_slim_value) + .collect::, _>>() + .map(SlimValue::List) + } +} + +impl FromSlimValue for [T; N] +where + T: FromSlimValue, +{ + fn from_slim_value(value: SlimValue) -> Result { + let values = Vec::::from_slim_value(value)?; + values.try_into().map_err(|values: Vec| { + ExecuteMethodError::ArgumentParsingError(format!( + "expected a list with {N} items, got {}", + values.len() + )) + }) + } +} + +impl IntoSlimValue for [T; N] +where + T: IntoSlimValue, +{ + fn into_slim_value(self) -> Result { + self.into_iter().collect::>().into_slim_value() + } +} + +impl IntoSlimValue for () { + fn into_slim_value(self) -> Result { + Ok(SlimValue::Void) + } +} + +impl IntoSlimValue for Result +where + T: IntoSlimValue, + E: fmt::Display, +{ + fn into_slim_value(self) -> Result { + match self { + Ok(value) => value.into_slim_value(), + Err(error) => Err(ExecuteMethodError::ExecutionError(error.to_string())), + } + } +} + +fn conversion_error(expected: &str, actual: &SlimValue) -> ExecuteMethodError { + ExecuteMethodError::ArgumentParsingError(format!("expected {expected}, got {actual:?}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ObjectFixture; + + impl crate::SlimFixture for ObjectFixture { + fn execute_method( + &mut self, + method: &str, + _args: Vec, + ) -> Result { + Ok(SlimValue::String(method.into())) + } + } + + #[test] + fn nested_lists_round_trip_through_conversions() { + let value = vec![vec![1_i64, 2], vec![3]].into_slim_value().unwrap(); + assert!(matches!(value, SlimValue::List(_))); + assert_eq!( + vec![vec![1_i64, 2], vec![3]], + Vec::>::from_slim_value(value).unwrap() + ); + } + + #[test] + fn null_and_void_are_distinct() { + assert!(matches!( + Option::::None.into_slim_value().unwrap(), + SlimValue::Null + )); + assert!(matches!(().into_slim_value().unwrap(), SlimValue::Void)); + assert_eq!( + None, + Option::::from_slim_value(SlimValue::Null).unwrap() + ); + } + + #[test] + fn invalid_primitive_is_a_conversion_error() { + assert!(matches!( + i64::from_slim_value(SlimValue::String("not a number".into())), + Err(ExecuteMethodError::ArgumentParsingError(_)) + )); + } + + #[test] + fn dates_use_the_documented_slim_format() { + let date = NaiveDate::from_ymd_opt(1970, 10, 10).unwrap(); + let value = date.into_slim_value().unwrap(); + assert_eq!(SlimValue::String("10-Oct-1970".into()), value); + assert_eq!(date, NaiveDate::from_slim_value(value).unwrap()); + } + + #[test] + fn lists_and_objects_have_useful_runtime_conversions() { + let list = SlimValue::List(vec![ + SlimValue::String("one".into()), + SlimValue::List(vec![SlimValue::String("two".into())]), + ]); + assert_eq!("[one, [two]]", list.as_text()); + + let object = SlimObject::new(42_i64, "the answer"); + let value = object.clone().into_slim_value().unwrap(); + assert_eq!( + 42, + *SlimObject::from_slim_value(value) + .unwrap() + .downcast::() + .unwrap() + ); + + let fixture = SlimObject::fixture(ObjectFixture, "fixture"); + let callable = fixture.as_fixture().unwrap(); + assert_eq!( + Ok(SlimValue::String("called".into())), + callable.borrow_mut().execute_method("called", Vec::new()) + ); + } + + #[test] + fn result_errors_are_execution_errors() { + let value: Result = Err("fixture failed"); + assert_eq!( + Err(ExecuteMethodError::ExecutionError("fixture failed".into())), + value.into_slim_value() + ); + } +} diff --git a/rust_slim/tests/compile_fail.rs b/rust_slim/tests/compile_fail.rs new file mode 100644 index 0000000..4ebf9d3 --- /dev/null +++ b/rust_slim/tests/compile_fail.rs @@ -0,0 +1,7 @@ +#![cfg(feature = "macros")] + +#[test] +fn fixture_macro_reports_unsupported_input_at_compile_time() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/fixture_*.rs"); +} diff --git a/rust_slim/tests/fixture_macro.rs b/rust_slim/tests/fixture_macro.rs new file mode 100644 index 0000000..5e9e952 --- /dev/null +++ b/rust_slim/tests/fixture_macro.rs @@ -0,0 +1,119 @@ +#![cfg(feature = "macros")] + +use rust_slim::{ + fixture, ClassPath, ExecuteMethodError, FromSlimValue, IntoSlimValue, SlimFixture, SlimValue, +}; +use std::marker::PhantomData; + +#[derive(Default)] +struct MacroFixture; + +struct Word(String); + +struct GenericFixture(PhantomData); + +#[fixture] +impl GenericFixture { + pub fn value(&self) -> &'static str { + "generic" + } +} + +impl FromSlimValue for Word { + fn from_slim_value(value: SlimValue) -> Result { + match value { + SlimValue::String(value) => Ok(Self(value.to_uppercase())), + _ => Err(ExecuteMethodError::ArgumentParsingError( + "expected a word".into(), + )), + } + } +} + +impl IntoSlimValue for Word { + fn into_slim_value(self) -> Result { + Ok(SlimValue::String(self.0)) + } +} + +#[fixture("Test.MacroFixture")] +impl MacroFixture { + pub fn helper() -> &'static str { + "associated functions are not fixture methods" + } + + pub fn nested(&self, values: Vec>) -> Vec> { + values + } + + pub fn custom(&self, value: Word) -> Word { + value + } + + pub fn fails(&self) -> Result<(), &'static str> { + Err("fixture failed") + } +} + +#[test] +fn macro_converts_nested_values_and_custom_types() { + let mut fixture = MacroFixture; + assert_eq!( + Ok(SlimValue::List(vec![ + SlimValue::List(vec![SlimValue::String("1".into())]), + SlimValue::List(vec![ + SlimValue::String("2".into()), + SlimValue::String("3".into()) + ]), + ])), + fixture.execute_method( + "nested", + vec![SlimValue::List(vec![ + SlimValue::List(vec![SlimValue::String("1".into())]), + SlimValue::List(vec![ + SlimValue::String("2".into()), + SlimValue::String("3".into()) + ]), + ])], + ) + ); + assert_eq!( + Ok(SlimValue::String("HELLO".into())), + fixture.execute_method("custom", vec![SlimValue::String("hello".into())]) + ); +} + +#[test] +fn macro_checks_arity_before_accessing_arguments() { + let mut fixture = MacroFixture; + assert_eq!( + Err(ExecuteMethodError::MethodNotFound { + method: "custom".into(), + class: "Test.MacroFixture".into(), + }), + fixture.execute_method("custom", Vec::new()) + ); + assert_eq!( + "associated functions are not fixture methods", + MacroFixture::helper() + ); +} + +#[test] +fn macro_turns_result_errors_into_execution_errors() { + let mut fixture = MacroFixture; + assert_eq!( + Err(ExecuteMethodError::ExecutionError("fixture failed".into())), + fixture.execute_method("fails", Vec::new()) + ); +} + +#[test] +fn macro_supports_generic_fixture_types() { + let mut fixture = GenericFixture::(PhantomData); + assert_eq!( + Ok(SlimValue::String("generic".into())), + fixture.execute_method("value", Vec::new()) + ); + assert!(GenericFixture::::class_path().ends_with("GenericFixture")); +} diff --git a/rust_slim/tests/ui/fixture_async_method.rs b/rust_slim/tests/ui/fixture_async_method.rs new file mode 100644 index 0000000..07b1a46 --- /dev/null +++ b/rust_slim/tests/ui/fixture_async_method.rs @@ -0,0 +1,10 @@ +use rust_slim::fixture; + +struct Fixture; + +#[fixture] +impl Fixture { + pub async fn asynchronous(&self) {} +} + +fn main() {} diff --git a/rust_slim/tests/ui/fixture_async_method.stderr b/rust_slim/tests/ui/fixture_async_method.stderr new file mode 100644 index 0000000..ddaf90b --- /dev/null +++ b/rust_slim/tests/ui/fixture_async_method.stderr @@ -0,0 +1,5 @@ +error: const, async, unsafe, extern, and variadic fixture methods are not supported + --> tests/ui/fixture_async_method.rs:7:9 + | +7 | pub async fn asynchronous(&self) {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/rust_slim/tests/ui/fixture_consuming_receiver.rs b/rust_slim/tests/ui/fixture_consuming_receiver.rs new file mode 100644 index 0000000..6652691 --- /dev/null +++ b/rust_slim/tests/ui/fixture_consuming_receiver.rs @@ -0,0 +1,10 @@ +use rust_slim::fixture; + +struct Fixture; + +#[fixture] +impl Fixture { + pub fn consuming(self) {} +} + +fn main() {} diff --git a/rust_slim/tests/ui/fixture_consuming_receiver.stderr b/rust_slim/tests/ui/fixture_consuming_receiver.stderr new file mode 100644 index 0000000..476e6f1 --- /dev/null +++ b/rust_slim/tests/ui/fixture_consuming_receiver.stderr @@ -0,0 +1,5 @@ +error: fixture methods must use an &self or &mut self receiver + --> tests/ui/fixture_consuming_receiver.rs:7:22 + | +7 | pub fn consuming(self) {} + | ^^^^ diff --git a/rust_slim/tests/ui/fixture_generic_method.rs b/rust_slim/tests/ui/fixture_generic_method.rs new file mode 100644 index 0000000..7157992 --- /dev/null +++ b/rust_slim/tests/ui/fixture_generic_method.rs @@ -0,0 +1,10 @@ +use rust_slim::fixture; + +struct Fixture; + +#[fixture] +impl Fixture { + pub fn generic(&self, _value: T) {} +} + +fn main() {} diff --git a/rust_slim/tests/ui/fixture_generic_method.stderr b/rust_slim/tests/ui/fixture_generic_method.stderr new file mode 100644 index 0000000..03190a8 --- /dev/null +++ b/rust_slim/tests/ui/fixture_generic_method.stderr @@ -0,0 +1,5 @@ +error: generic fixture methods are not supported + --> tests/ui/fixture_generic_method.rs:7:19 + | +7 | pub fn generic(&self, _value: T) {} + | ^^^ diff --git a/rust_slim/tests/ui/fixture_invalid_attribute.rs b/rust_slim/tests/ui/fixture_invalid_attribute.rs new file mode 100644 index 0000000..6ad5766 --- /dev/null +++ b/rust_slim/tests/ui/fixture_invalid_attribute.rs @@ -0,0 +1,8 @@ +use rust_slim::fixture; + +struct Fixture; + +#[fixture(42)] +impl Fixture {} + +fn main() {} diff --git a/rust_slim/tests/ui/fixture_invalid_attribute.stderr b/rust_slim/tests/ui/fixture_invalid_attribute.stderr new file mode 100644 index 0000000..ad28b35 --- /dev/null +++ b/rust_slim/tests/ui/fixture_invalid_attribute.stderr @@ -0,0 +1,5 @@ +error: expected string literal + --> tests/ui/fixture_invalid_attribute.rs:5:11 + | +5 | #[fixture(42)] + | ^^ diff --git a/rust_slim_macros/src/lib.rs b/rust_slim_macros/src/lib.rs index ba1e353..e03780d 100644 --- a/rust_slim_macros/src/lib.rs +++ b/rust_slim_macros/src/lib.rs @@ -1,96 +1,172 @@ -use proc_macro::{TokenStream, TokenTree}; +use proc_macro::TokenStream; use quote::quote; -use syn::{parse_macro_input, FnArg, ImplItem, ItemImpl, Type, Visibility}; +use syn::{ + parse::Parser, parse_macro_input, Error, FnArg, ImplItem, ItemImpl, LitStr, Result, Type, + Visibility, +}; +/// Exposes the public instance methods in an implementation block as SliM +/// fixture methods. +/// +/// Unsupported signatures produce compile errors at the method declaration. #[proc_macro_attribute] pub fn fixture(args: TokenStream, input: TokenStream) -> TokenStream { let impl_fixture = parse_macro_input!(input as ItemImpl); - let generics = &impl_fixture.generics; - let ty = &impl_fixture.self_ty; + match expand_fixture(args, impl_fixture) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} - let class_path = get_class_path(args, ty); - let impl_methods = impl_slim_fixture_methods(ty, &class_path, &impl_fixture.items); +fn expand_fixture(args: TokenStream, impl_fixture: ItemImpl) -> Result { + if impl_fixture.trait_.is_some() { + return Err(Error::new_spanned( + &impl_fixture, + "fixture must be applied to an inherent impl block", + )); + } + let (impl_generics, _type_generics, where_clause) = impl_fixture.generics.split_for_impl(); + let ty = &impl_fixture.self_ty; + let class_path = get_class_path(args, ty)?; + let impl_methods = impl_slim_fixture_methods(ty, &class_path, &impl_fixture.items)?; - quote! { + Ok(quote! { #impl_fixture - impl #generics ::rust_slim::SlimFixture for #ty { + impl #impl_generics ::rust_slim::SlimFixture for #ty #where_clause { #impl_methods } - impl #generics ::rust_slim::ClassPath for #ty { + impl #impl_generics ::rust_slim::ClassPath for #ty #where_clause { fn class_path() -> String { #class_path } } - } - .into() + }) } fn impl_slim_fixture_methods( ty: &Type, class_path: &proc_macro2::TokenStream, items: &[ImplItem], -) -> proc_macro2::TokenStream { +) -> Result { let mut methods = Vec::new(); for item in items { - if let ImplItem::Fn(impl_fn) = item { - if !matches!(impl_fn.vis, Visibility::Public(_)) { - continue; - } - let has_receiver = impl_fn - .sig - .inputs - .iter() - .any(|param| matches!(param, FnArg::Receiver(_))); - if !has_receiver { - continue; - } - let _n_args = impl_fn.sig.inputs.len() - 1; - let method = impl_fn.sig.ident.to_string(); - let ident = &impl_fn.sig.ident; - let args = impl_fn.sig.inputs.iter().skip(1).enumerate().map(|(i, fn_arg)| { - let FnArg::Typed(typed_arg) = fn_arg else { - panic!("Expected a typed arg") + let ImplItem::Fn(impl_fn) = item else { + continue; + }; + if !matches!(impl_fn.vis, Visibility::Public(_)) { + continue; + } + + if impl_fn.sig.constness.is_some() + || impl_fn.sig.asyncness.is_some() + || impl_fn.sig.unsafety.is_some() + || impl_fn.sig.abi.is_some() + || impl_fn.sig.variadic.is_some() + { + return Err(Error::new_spanned( + &impl_fn.sig, + "const, async, unsafe, extern, and variadic fixture methods are not supported", + )); + } + + let Some(FnArg::Receiver(receiver)) = impl_fn.sig.inputs.first() else { + // Associated functions are not callable SliM methods. Constructor + // annotations will process them separately when constructor + // support is added. + continue; + }; + if receiver.reference.is_none() || receiver.colon_token.is_some() { + return Err(Error::new_spanned( + receiver, + "fixture methods must use an &self or &mut self receiver", + )); + } + if !impl_fn.sig.generics.params.is_empty() { + return Err(Error::new_spanned( + &impl_fn.sig.generics, + "generic fixture methods are not supported", + )); + } + + let arity = impl_fn.sig.inputs.len() - 1; + let method = impl_fn.sig.ident.to_string(); + let ident = &impl_fn.sig.ident; + let arguments = impl_fn + .sig + .inputs + .iter() + .skip(1) + .enumerate() + .map(|(index, argument)| { + let FnArg::Typed(argument) = argument else { + unreachable!("only the first fixture argument can be a receiver") }; - let ty = &typed_arg.ty; - quote!{args[#i].parse::<#ty>().map_err(|e| ::rust_slim::ExecuteMethodError::ArgumentParsingError(e.to_string()))?} + let ty = &argument.ty; + quote! { + <#ty as ::rust_slim::FromSlimValue>::from_slim_value( + supplied_args.next().expect("argument count checked before conversion"), + ).map_err(|error| error.for_argument(#index))? + } }); - methods.push(quote! { - #method => ::rust_slim::ToSlimResultString::to_slim_result_string(#ty::#ident(self,#(#args),*)) - }); - } + methods.push(quote! { + #method => { + if args.len() != #arity { + return Err(::rust_slim::ExecuteMethodError::MethodNotFound { + method: method.to_string(), + class: #class_path, + }); + } + let mut supplied_args = args.into_iter(); + ::rust_slim::IntoSlimValue::into_slim_value(<#ty>::#ident(self, #(#arguments),*)) + } + }); } - quote! { - fn execute_method(&mut self, method: &str, args: ::std::vec::Vec<::std::string::String>) -> ::std::result::Result<::std::string::String, ::rust_slim::ExecuteMethodError> { + Ok(quote! { + fn execute_method( + &mut self, + method: &str, + args: ::std::vec::Vec<::rust_slim::SlimValue>, + ) -> ::std::result::Result<::rust_slim::SlimValue, ::rust_slim::ExecuteMethodError> { match method { #(#methods,)* - _ => Err(::rust_slim::ExecuteMethodError::MethodNotFound{method: method.to_string(), class: #class_path}), + _ => Err(::rust_slim::ExecuteMethodError::MethodNotFound { + method: method.to_string(), + class: #class_path, + }), } } - } + }) } -fn get_class_path(args: TokenStream, ty: &Type) -> proc_macro2::TokenStream { - let args: Vec = args - .into_iter() - .map(|t| match t { - TokenTree::Literal(literal) => literal.to_string().trim_matches('"').to_string(), - _ => panic!("Expected a string path"), - }) - .collect(); - if !args.is_empty() { - let path = args[0].to_string(); - quote! { - #path.into() - } - } else { - let path = match ty { - Type::Path(path) => path.path.get_ident().unwrap().to_string(), - _ => panic!("Expected an impl over a type"), - }; - quote! { - ::rust_slim::from_rust_module_path_to_class_path(&format!("{}::{}", module_path!(), #path)) - } +fn get_class_path(args: TokenStream, ty: &Type) -> Result { + let parser = syn::punctuated::Punctuated::::parse_terminated; + let args = parser.parse(args)?; + if args.len() > 1 { + return Err(Error::new_spanned( + args, + "expected at most one fixture class path", + )); + } + if let Some(path) = args.first() { + return Ok(quote! { #path.into() }); } + + let Type::Path(path) = ty else { + return Err(Error::new_spanned( + ty, + "the fixture implementation must target a named type", + )); + }; + let Some(ident) = path.path.segments.last().map(|segment| &segment.ident) else { + return Err(Error::new_spanned( + ty, + "the fixture implementation must target a named type", + )); + }; + let path = ident.to_string(); + Ok(quote! { + ::rust_slim::from_rust_module_path_to_class_path(&format!("{}::{}", module_path!(), #path)) + }) } diff --git a/slim_protocol/src/lib.rs b/slim_protocol/src/lib.rs index c6d2982..6e4c3b2 100644 --- a/slim_protocol/src/lib.rs +++ b/slim_protocol/src/lib.rs @@ -160,6 +160,18 @@ pub enum SlimValue { List(Vec), } +impl From for SlimValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for SlimValue { + fn from(value: &str) -> Self { + Self::String(value.into()) + } +} + #[derive(Debug, PartialEq, Eq, Clone)] pub enum Instruction { /// An instruction list with an unknown operation or an invalid number of @@ -176,17 +188,13 @@ pub enum Instruction { id: Id, instance: String, class: String, - /// Instruction arguments remain string-only until the typed runtime - /// value model is introduced. - args: Vec, + args: Vec, }, Call { id: Id, instance: String, function: String, - /// Instruction arguments remain string-only until the typed runtime - /// value model is introduced. - args: Vec, + args: Vec, }, #[allow(dead_code)] CallAndAssign { @@ -194,15 +202,13 @@ pub enum Instruction { symbol: String, instance: String, function: String, - /// Instruction arguments remain string-only until the typed runtime - /// value model is introduced. - args: Vec, + args: Vec, }, #[allow(dead_code)] Assign { id: Id, symbol: String, - value: String, + value: SlimValue, }, } diff --git a/slim_protocol/src/slim_deserialize.rs b/slim_protocol/src/slim_deserialize.rs index 36de28e..88ab844 100644 --- a/slim_protocol/src/slim_deserialize.rs +++ b/slim_protocol/src/slim_deserialize.rs @@ -73,9 +73,7 @@ impl FromSlimReader for InstructionResult { impl FromSlimReader for Instruction { fn from_reader(reader: &mut impl BufRead) -> Result { - Ok(instruction_from_values(parse_list_payload( - &read_outer_payload(reader)?, - )?)) + instruction_from_values(parse_list_payload(&read_outer_payload(reader)?)?) } } @@ -88,7 +86,7 @@ impl FromSlimReader for ByeOrSlimInstructions { let instructions = parse_list_payload(&payload)? .into_iter() - .map(|instruction| parse_list_payload(&instruction).map(instruction_from_values)) + .map(|instruction| parse_list_payload(&instruction).and_then(instruction_from_values)) .collect::, _>>()?; Ok(Self::Instructions(instructions)) } @@ -154,46 +152,58 @@ fn parse_slim_value(value: String) -> Result { Ok(SlimValue::String(value)) } -fn instruction_from_values(fields: Vec) -> Instruction { +fn instruction_from_values(fields: Vec) -> Result { let id = Id::from(fields.first().cloned().unwrap_or_default()); let malformed = || Instruction::Malformed { id: id.clone(), fields: fields.clone(), }; let Some(operation) = fields.get(1).map(String::as_str) else { - return malformed(); + return Ok(malformed()); }; match operation { - "import" if fields.len() == 3 => Instruction::Import { + "import" if fields.len() == 3 => Ok(Instruction::Import { id, path: fields[2].clone(), - }, - "make" if fields.len() >= 4 => Instruction::Make { + }), + "make" if fields.len() >= 4 => Ok(Instruction::Make { id, instance: fields[2].clone(), class: fields[3].clone(), - args: fields[4..].to_vec(), - }, - "call" if fields.len() >= 4 => Instruction::Call { + args: fields[4..] + .iter() + .cloned() + .map(parse_slim_value) + .collect::>()?, + }), + "call" if fields.len() >= 4 => Ok(Instruction::Call { id, instance: fields[2].clone(), function: fields[3].clone(), - args: fields[4..].to_vec(), - }, - "callAndAssign" if fields.len() >= 5 => Instruction::CallAndAssign { + args: fields[4..] + .iter() + .cloned() + .map(parse_slim_value) + .collect::>()?, + }), + "callAndAssign" if fields.len() >= 5 => Ok(Instruction::CallAndAssign { id, symbol: fields[2].clone(), instance: fields[3].clone(), function: fields[4].clone(), - args: fields[5..].to_vec(), - }, - "assign" if fields.len() == 4 => Instruction::Assign { + args: fields[5..] + .iter() + .cloned() + .map(parse_slim_value) + .collect::>()?, + }), + "assign" if fields.len() == 4 => Ok(Instruction::Assign { id, symbol: fields[2].clone(), - value: fields[3].clone(), - }, - _ => malformed(), + value: parse_slim_value(fields[3].clone())?, + }), + _ => Ok(malformed()), } } @@ -455,7 +465,7 @@ mod test { Instruction::Assign { id: Id::from("assign"), symbol: "value".into(), - value: "42".into(), + value: SlimValue::List(vec!["42".into()]), }, ]; let wire = instructions.to_slim_string(); @@ -466,6 +476,25 @@ mod test { Ok(()) } + #[test] + fn instruction_arguments_preserve_nested_lists() -> Result<(), Box> { + let instruction = Instruction::Call { + id: Id::from("nested"), + instance: "fixture".into(), + function: "accept".into(), + args: vec![SlimValue::List(vec![ + SlimValue::String("one".into()), + SlimValue::List(vec![SlimValue::String("two".into())]), + ])], + }; + let wire = instruction.to_slim_string(); + assert_eq!( + instruction, + Instruction::from_reader(&mut Cursor::new(wire.as_bytes()))? + ); + Ok(()) + } + #[test] fn structurally_invalid_instruction_list_is_a_wire_error() { let malformed = ["[000002:000002:id;]"].to_slim_string(); diff --git a/slim_protocol/src/slim_serialize.rs b/slim_protocol/src/slim_serialize.rs index ca77f2d..0ef0537 100644 --- a/slim_protocol/src/slim_serialize.rs +++ b/slim_protocol/src/slim_serialize.rs @@ -106,9 +106,10 @@ impl ToSlimString for Instruction { [id.0.as_str(), "call", instance.as_str(), function.as_str()], args, ), - Self::Assign { id, symbol, value } => { - [id.0.as_str(), "assign", symbol.as_str(), value.as_str()].to_slim_string() - } + Self::Assign { id, symbol, value } => instruction_parts( + [id.0.as_str(), "assign", symbol.as_str()], + std::slice::from_ref(value), + ), Self::CallAndAssign { id, symbol, @@ -129,8 +130,11 @@ impl ToSlimString for Instruction { } } -fn instruction_parts(head: [&str; N], args: &[String]) -> SlimString { - let mut values = head.into_iter().map(str::to_owned).collect::>(); +fn instruction_parts(head: [&str; N], args: &[SlimValue]) -> SlimString { + let mut values = head + .into_iter() + .map(|value| SlimValue::String(value.into())) + .collect::>(); values.extend(args.iter().cloned()); values.to_slim_string() } @@ -217,7 +221,7 @@ mod test { id, instance: "fixture".into(), function: "answer".into(), - args: vec!["😀".into()], + args: vec![SlimValue::String("😀".into())], }; assert_eq!( b"000096:[000005:000026:01HFM0NQM3ZS6BBX0ZH6VA6DJX:000004:call:000007:fixture:000006:answer:000002:\xF0\x9F\x98\x80:]", diff --git a/temoc/examples/calculator.rs b/temoc/examples/calculator.rs index 71062ca..25f6125 100644 --- a/temoc/examples/calculator.rs +++ b/temoc/examples/calculator.rs @@ -47,7 +47,7 @@ mod fixtures { } fn main() -> Result<()> { - let port = args().skip(1).next().unwrap_or("8085".to_string()); + let port = args().nth(1).unwrap_or("8085".to_string()); let mut server = build_server(&port)?; server.add_fixture::(); diff --git a/temoc/src/processor/slim_instructions_from_commands.rs b/temoc/src/processor/slim_instructions_from_commands.rs index e3941b2..3a9e29d 100644 --- a/temoc/src/processor/slim_instructions_from_commands.rs +++ b/temoc/src/processor/slim_instructions_from_commands.rs @@ -2,7 +2,7 @@ use anyhow::Result; use std::fmt::Display; use ulid::Ulid; -use slim_protocol::{Id, Instruction}; +use slim_protocol::{Id, Instruction, SlimValue}; use super::markdown_commands::{ Class, DecisionTableType, MarkdownCommand, MethodName, Position, Snooze, Value, @@ -75,7 +75,7 @@ pub fn get_instructions_from_commands( id: id.clone(), instance: table_instance.clone(), function: setter_name.0.clone(), - args: vec![value], + args: vec![SlimValue::String(value)], }); expected_result.push(( ExpectedResult::null_or_void(id, position, Some(setter_name)), @@ -149,8 +149,11 @@ pub fn get_instructions_from_commands( } } DecisionTableType::SingleMethod(ref method_name) => { - let params = - row.setters.into_iter().map(|setter| setter.1 .0).collect(); + let params = row + .setters + .into_iter() + .map(|setter| SlimValue::String(setter.1 .0)) + .collect(); let result = row .getters .into_iter() @@ -472,7 +475,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "setA" && args == &["1".to_string()] + } if *expected_instance == instance && function == "setA" && args == [SlimValue::String("1".into())] )); assert!(matches!( expected_result.remove(0), @@ -485,7 +488,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "setB" && args == &["2".to_string()] + } if *expected_instance == instance && function == "setB" && args == &[SlimValue::String("2".into())] )); assert!(matches!( &expected_result.remove(0), @@ -578,7 +581,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "setA" && args == &["3".to_string()] + } if *expected_instance == instance && function == "setA" && args == &[SlimValue::String("3".into())] )); assert!(matches!( &expected_result.remove(0), @@ -591,7 +594,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "setB" && args == &["4".to_string()] + } if *expected_instance == instance && function == "setB" && args == &[SlimValue::String("4".into())] )); assert!(matches!( &expected_result.remove(0), @@ -747,7 +750,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "setA" && args == &["1".to_string()] + } if *expected_instance == instance && function == "setA" && args == [SlimValue::String("1".into())] )); assert!(matches!( expected_result.remove(0), @@ -926,7 +929,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "Method" && args == &["1".to_string(), "2".to_string()] + } if *expected_instance == instance && function == "Method" && args == [SlimValue::String("1".into()), SlimValue::String("2".into())] )); assert!(matches!( expected_result.remove(0), @@ -963,7 +966,7 @@ mod test { instance: expected_instance, function, args - } if *expected_instance == instance && function == "Method" && args == &["3".to_string(), "4".to_string()] + } if *expected_instance == instance && function == "Method" && args == [SlimValue::String("3".into()), SlimValue::String("4".into())] )); assert!(matches!( expected_result.remove(0), diff --git a/temoc/src/processor/validate_result.rs b/temoc/src/processor/validate_result.rs index daa7334..58c2b51 100644 --- a/temoc/src/processor/validate_result.rs +++ b/temoc/src/processor/validate_result.rs @@ -478,7 +478,7 @@ mod test { assert_eq!( vec![ (format!("Different ID in response. Expected {id_1} but got {id_2}",), Snooze::not_snooze()), - (format!("Expected NULL or VOID or NOT FOUND, got OK in test_file.md:0:0",), Snooze::not_snooze()), + ("Expected NULL or VOID or NOT FOUND, got OK in test_file.md:0:0".to_string(), Snooze::not_snooze()), (format!("Different ID in response. Expected {id_1} but got {id_2}",), Snooze::not_snooze()), (format!( "Expected NULL or VOID, got OK in test_file.md:{position} for method call TestMethod" @@ -594,9 +594,9 @@ mod test { )?; assert_eq!( vec![ - (format!("Expected SET SYMBOL `Symbol`, got OK in test_file.md:0:0 for method call TestMethod"), Snooze::not_snooze()), - (format!("Expected SET SYMBOL `Symbol`, got VOID in test_file.md:0:0 for method call TestMethod"), Snooze::not_snooze()), - (format!("Expected SET SYMBOL `Symbol`, got Exception `Error` in test_file.md:0:0 for method call TestMethod"), Snooze::not_snooze()), + ("Expected SET SYMBOL `Symbol`, got OK in test_file.md:0:0 for method call TestMethod".to_string(), Snooze::not_snooze()), + ("Expected SET SYMBOL `Symbol`, got VOID in test_file.md:0:0 for method call TestMethod".to_string(), Snooze::not_snooze()), + ("Expected SET SYMBOL `Symbol`, got Exception `Error` in test_file.md:0:0 for method call TestMethod".to_string(), Snooze::not_snooze()), ], result );