diff --git a/zlink-codegen/src/codegen.rs b/zlink-codegen/src/codegen.rs index aff0e416..58ff19d8 100644 --- a/zlink-codegen/src/codegen.rs +++ b/zlink-codegen/src/codegen.rs @@ -516,6 +516,7 @@ fn type_to_rust(ty: &Type) -> Result { format!("Option<{}>", inner_rust) } Type::Custom(name) => name.to_pascal_case(), + Type::Any => "serde_json::Value".to_string(), }) } @@ -550,6 +551,7 @@ fn type_to_rust_param(ty: &Type) -> Result { format!("Option<{}>", inner_rust) } Type::Custom(name) => format!("&{}", name.to_pascal_case()), + Type::Any => "&serde_json::Value".to_string(), }) } @@ -572,6 +574,7 @@ fn type_to_rust_param_elem(ty: &Type) -> Result { format!("std::collections::HashMap<&str, {}>", value_rust) } Type::ForeignObject => "serde_json::Value".to_string(), + Type::Any => "serde_json::Value".to_string(), Type::Optional(inner_type) => { let inner_rust = type_to_rust_param_elem(inner_type.inner())?; format!("Option<{}>", inner_rust) @@ -613,6 +616,7 @@ fn type_to_rust_output(ty: &Type) -> Result { format!("std::collections::HashMap<&'a str, {}>", value_rust) } Type::ForeignObject => "serde_json::Value".to_string(), + Type::Any => "serde_json::Value".to_string(), Type::Optional(inner_type) => { // For optional outputs, recursively apply type_to_rust_output to maintain // correct reference types for strings within collections diff --git a/zlink-codegen/test-integration/anytype.idl b/zlink-codegen/test-integration/anytype.idl new file mode 100644 index 00000000..1b98bc3e --- /dev/null +++ b/zlink-codegen/test-integration/anytype.idl @@ -0,0 +1,11 @@ +interface test.AnyType + +type Config ( + name: string, + metadata: any, + extra: ?any +) + +method SetConfig(config: Config, data: any) -> (id: string) +method GetConfig(id: string) -> (config: Config) +method ProcessData(data: any) -> (result: any) diff --git a/zlink-codegen/test-integration/build.rs b/zlink-codegen/test-integration/build.rs index 5f4426c2..5291be78 100644 --- a/zlink-codegen/test-integration/build.rs +++ b/zlink-codegen/test-integration/build.rs @@ -5,7 +5,13 @@ fn main() { let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); // Process all IDL files. - let idl_files = ["test.idl", "calc.idl", "storage.idl", "camelcase.idl"]; + let idl_files = [ + "test.idl", + "calc.idl", + "storage.idl", + "camelcase.idl", + "anytype.idl", + ]; // Read all IDL file contents first (they need to stay alive for Interface lifetimes). let mut contents = Vec::new(); diff --git a/zlink-codegen/test-integration/src/lib.rs b/zlink-codegen/test-integration/src/lib.rs index 3b1c7fc1..104684d7 100644 --- a/zlink-codegen/test-integration/src/lib.rs +++ b/zlink-codegen/test-integration/src/lib.rs @@ -356,4 +356,102 @@ mod tests { let result = conn.add(i64::MAX - 1, 1).await.unwrap().unwrap(); assert_eq!(result.result, i64::MAX); } + + #[tokio::test] + async fn test_anytype_proxy() { + let responses = [ + // Response for SetConfig: returns an id. + json!({ + "parameters": { + "id": "config-1" + } + }) + .to_string(), + // Response for GetConfig: returns a Config with various any values. + json!({ + "parameters": { + "config": { + "name": "my-config", + "metadata": { + "version": 2, + "features": ["a", "b"] + }, + "extra": 42 + } + } + }) + .to_string(), + // Response for GetConfig: metadata is a string, extra is null. + json!({ + "parameters": { + "config": { + "name": "simple-config", + "metadata": "just a string", + "extra": null + } + } + }) + .to_string(), + // Response for ProcessData: returns an array as the any result. + json!({ + "parameters": { + "result": [1, "two", true, null] + } + }) + .to_string(), + // Response for ProcessData: returns a nested object. + json!({ + "parameters": { + "result": { + "nested": { + "deep": true + } + } + } + }) + .to_string(), + ]; + + let socket = + MockSocket::with_responses(&responses.iter().map(|s| s.as_str()).collect::>()); + let mut conn = Connection::new(socket); + + // Test SetConfig with structured metadata. + let config = Config { + name: "my-config".to_string(), + metadata: json!({"version": 2, "features": ["a", "b"]}), + extra: Some(json!(42)), + }; + let result = conn + .set_config(&config, &json!("extra-data")) + .await + .unwrap() + .unwrap(); + assert_eq!(result.id, "config-1"); + + // Test GetConfig returns Config with object metadata. + let result = conn.get_config("config-1").await.unwrap().unwrap(); + assert_eq!(result.config.name, "my-config"); + assert_eq!(result.config.metadata["version"], 2); + assert_eq!(result.config.metadata["features"][0], "a"); + assert_eq!(result.config.extra, Some(json!(42))); + + // Test GetConfig with string metadata and null extra. + let result = conn.get_config("config-2").await.unwrap().unwrap(); + assert_eq!(result.config.name, "simple-config"); + assert_eq!(result.config.metadata, json!("just a string")); + assert_eq!(result.config.extra, None); + + // Test ProcessData returning an array. + let result = conn.process_data(&json!("input")).await.unwrap().unwrap(); + assert_eq!(result.result, json!([1, "two", true, null])); + + // Test ProcessData returning a nested object. + let result = conn + .process_data(&json!({"key": "value"})) + .await + .unwrap() + .unwrap(); + assert_eq!(result.result["nested"]["deep"], true); + } } diff --git a/zlink-codegen/tests/codegen.rs b/zlink-codegen/tests/codegen.rs index e6609ef0..79e81e58 100644 --- a/zlink-codegen/tests/codegen.rs +++ b/zlink-codegen/tests/codegen.rs @@ -272,3 +272,40 @@ fn test_camelcase_rename_attributes() { assert!(code.contains(r#"#[zlink(rename = "userId")]"#)); assert!(code.contains("user_id: i64")); } + +#[test] +fn test_interface_with_any_type() { + let idl = r#" +interface org.example.anytype + +type Config ( + name: string, + metadata: any, + extra: ?any, + items: []any, + tags: [string]any +) + +method SetConfig(data: any) -> (result: any) +method GetData() -> (items: []any, map: [string]any) +"#; + + let interface = Interface::try_from(idl).unwrap(); + let code = generate_interface(&interface).unwrap(); + + // Check struct fields use serde_json::Value for any type. + assert!(code.contains("pub metadata: serde_json::Value")); + assert!(code.contains("pub extra: Option")); + assert!(code.contains("pub items: Vec")); + assert!(code.contains("pub tags: std::collections::HashMap")); + + // Check method input parameters use references. + assert!(code.contains("data: &serde_json::Value")); + + // Check single output returns serde_json::Value. + assert!(code.contains("serde_json::Value")); + + // Check GetData output struct contains array and map of any. + assert!(code.contains("Vec")); + assert!(code.contains("std::collections::HashMap<")); +} diff --git a/zlink-core/src/idl/parse/mod.rs b/zlink-core/src/idl/parse/mod.rs index 555c847c..228528be 100644 --- a/zlink-core/src/idl/parse/mod.rs +++ b/zlink-core/src/idl/parse/mod.rs @@ -121,6 +121,7 @@ fn primitive_type<'a>(input: &mut &'a [u8]) -> ModalResult, InputError< literal("float").map(|_| Type::Float), literal("string").map(|_| Type::String), literal("object").map(|_| Type::ForeignObject), + literal("any").map(|_| Type::Any), )) .parse_next(input) } diff --git a/zlink-core/src/idl/parse/tests.rs b/zlink-core/src/idl/parse/tests.rs index 0900a077..29f24dbb 100644 --- a/zlink-core/src/idl/parse/tests.rs +++ b/zlink-core/src/idl/parse/tests.rs @@ -12,6 +12,7 @@ fn parse_primitive_types() { assert_eq!(parse_type("float").unwrap(), Type::Float); assert_eq!(parse_type("string").unwrap(), Type::String); assert_eq!(parse_type("object").unwrap(), Type::ForeignObject); + assert_eq!(parse_type("any").unwrap(), Type::Any); } #[test] @@ -776,6 +777,92 @@ method AnotherMethod() -> () assert_eq!(comments[0].text(), "No space after hash"); } +#[test] +fn parse_any_composite_types() { + // Test optional any + match parse_type("?any").unwrap() { + Type::Optional(inner) => assert_eq!(*inner, Type::Any), + other => panic!("Expected optional any, got: {:?}", other), + } + + // Test array of any + match parse_type("[]any").unwrap() { + Type::Array(inner) => assert_eq!(*inner, Type::Any), + other => panic!("Expected array of any, got: {:?}", other), + } + + // Test map of any + match parse_type("[string]any").unwrap() { + Type::Map(inner) => assert_eq!(*inner, Type::Any), + other => panic!("Expected map of any, got: {:?}", other), + } + + // Test nested: optional array of any + match parse_type("?[]any").unwrap() { + Type::Optional(optional) => match &*optional { + Type::Array(array) => assert_eq!(*array, Type::Any), + other => panic!("Expected array inside optional, got: {:?}", other), + }, + other => panic!("Expected optional type, got: {:?}", other), + } +} + +#[test] +fn parse_interface_with_any_type() { + let input = r#" +interface org.example.anytest + +type Config ( + name: string, + metadata: any, + extra: ?any +) + +method SetConfig(config: Config, data: any) -> (result: any) +method GetData() -> (items: []any, map: [string]any) + "#; + + let interface = parse_interface(input).unwrap(); + assert_eq!(interface.name(), "org.example.anytest"); + assert_eq!(interface.custom_types().count(), 1); + assert_eq!(interface.methods().count(), 2); + + // Check custom type fields + let custom_types: Vec<_> = interface.custom_types().collect(); + let config = custom_types[0].as_object().unwrap(); + let fields: Vec<_> = config.fields().collect(); + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].name(), "name"); + assert_eq!(fields[0].ty(), &Type::String); + assert_eq!(fields[1].name(), "metadata"); + assert_eq!(fields[1].ty(), &Type::Any); + assert_eq!(fields[2].name(), "extra"); + assert_eq!(fields[2].ty(), &Type::Optional(TypeRef::new(&Type::Any))); + + // Check SetConfig method + let methods: Vec<_> = interface.methods().collect(); + let set_config = methods[0]; + assert_eq!(set_config.name(), "SetConfig"); + let inputs: Vec<_> = set_config.inputs().collect(); + assert_eq!(inputs.len(), 2); + assert_eq!(inputs[1].name(), "data"); + assert_eq!(inputs[1].ty(), &Type::Any); + let outputs: Vec<_> = set_config.outputs().collect(); + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].name(), "result"); + assert_eq!(outputs[0].ty(), &Type::Any); + + // Check GetData method + let get_data = methods[1]; + assert_eq!(get_data.name(), "GetData"); + let outputs: Vec<_> = get_data.outputs().collect(); + assert_eq!(outputs.len(), 2); + assert_eq!(outputs[0].name(), "items"); + assert_eq!(outputs[0].ty(), &Type::Array(TypeRef::new(&Type::Any))); + assert_eq!(outputs[1].name(), "map"); + assert_eq!(outputs[1].ty(), &Type::Map(TypeRef::new(&Type::Any))); +} + /// Parse a Varlink type from a string. fn parse_type(input: &str) -> Result, crate::Error> { parse_from_str(input, varlink_type) diff --git a/zlink-core/src/idl/type/mod.rs b/zlink-core/src/idl/type/mod.rs index 627d9352..8d6c78db 100644 --- a/zlink-core/src/idl/type/mod.rs +++ b/zlink-core/src/idl/type/mod.rs @@ -20,6 +20,8 @@ pub enum Type<'a> { String, /// Foreign untyped object. ForeignObject, + /// Any JSON value (systemd extension). + Any, /// Optional/nullable type. Optional(TypeRef<'a>), /// Array type. @@ -92,6 +94,7 @@ impl<'a> fmt::Display for Type<'a> { Type::Float => write!(f, "float"), Type::String => write!(f, "string"), Type::ForeignObject => write!(f, "object"), + Type::Any => write!(f, "any"), Type::Optional(optional) => write!(f, "?{optional}"), Type::Array(array) => write!(f, "[]{array}"), Type::Map(map) => write!(f, "[string]{map}"), @@ -179,6 +182,10 @@ mod tests { buf.clear(); write!(buf, "{}", Type::ForeignObject).unwrap(); assert_eq!(buf, "object"); + + buf.clear(); + write!(buf, "{}", Type::Any).unwrap(); + assert_eq!(buf, "any"); } #[test] diff --git a/zlink/examples/varlink-inspect.rs b/zlink/examples/varlink-inspect.rs index 2573b3fc..c16baf11 100644 --- a/zlink/examples/varlink-inspect.rs +++ b/zlink/examples/varlink-inspect.rs @@ -236,6 +236,7 @@ fn format_type(ty: &zlink::idl::Type<'_>) -> ColoredString { zlink::idl::Type::Float => "float".bright_blue(), zlink::idl::Type::String => "string".bright_blue(), zlink::idl::Type::ForeignObject => "object".bright_blue(), + zlink::idl::Type::Any => "any".bright_blue(), zlink::idl::Type::Array(type_ref) => format!("[{}]", format_type(type_ref)).bright_blue(), zlink::idl::Type::Optional(type_ref) => format!("?{}", format_type(type_ref)).bright_blue(), zlink::idl::Type::Map(type_ref) => {