diff --git a/Cargo.lock b/Cargo.lock index 4a5e4a6..2c1107e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,260 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-tuple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "trybuild" +version = "1.0.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cabaa10be1917331a313866bd94526343e03c77bcf69144b62b072ad35d47c" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-tuple", + "termcolor", + "toml", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wip-interface" +version = "0.1.0" +dependencies = [ + "trybuild", + "wip-interface-macros", + "wip-protocol", +] + +[[package]] +name = "wip-interface-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "wip-protocol" version = "0.1.0" @@ -12,3 +266,9 @@ version = "0.1.0" dependencies = [ "wip-protocol", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 575d55c..2c5e304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = [".", "crates/wip-protocol"] +members = [ + ".", + "crates/wip-interface", + "crates/wip-interface-macros", + "crates/wip-protocol", +] resolver = "3" [package] diff --git a/README.md b/README.md index e69de29..220bc93 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,67 @@ +# wip-rs + +Rust building blocks for the Web Interface Protocol (WIP). + +## Interface authoring + +Applications depend on `wip-interface`, normally imported as `wip`: + +```rust +use wip_interface as wip; + +#[derive(wip::WipType)] +struct Message { + /// Message body. + text: String, +} + +#[wip::interface] +pub trait Messenger { + /// Sends a message. + /// + /// # Returns + /// Whether the application accepted the message. + fn send( + &self, + /// Message to send. + message: Message, + ) -> wip::OperationResult; +} +``` + +The `interface` attribute and `WipType` derive are re-exported by the facade; +users do not import `wip-interface-macros` directly. The generated +`MessengerDefinition` constructs an `InterfaceDescriptor`, decodes named +arguments, encodes results, validates both boundaries, and provides typed +dispatch for any `T: Messenger`. + +### Type mapping + +| Rust | Protocol | +| --- | --- | +| `()` as an operation result | no returned value | +| `bool` | boolean | +| integer primitives | JavaScript-lossless integer | +| `f32`, `f64` | finite number | +| `String` | string | +| `wip::Bytes` | bytes | +| `wip::wip_protocol::JsonValue` | JSON | +| `wip::Entry` | entry | +| `Vec` | list | +| named-field `#[derive(wip::WipType)]` struct | named record | +| all-unit `#[derive(wip::WipType)]` enum | named enum | +| all-newtype `#[derive(wip::WipType)]` enum | named tagged union | + +`Option` is accepted only for a direct DTO field or operation parameter and +means that the named value may be omitted. It is not protocol null. Domain +failures are modeled in the application result (usually a derived union) and +returned through `Ok`; `wip::HostError` is only for host/runtime failures. + +Rust doc comments become descriptor metadata. For an operation, a `# Returns` +section documents the return declaration; text before that section documents +the operation itself. + +The supported interface slice is deliberately non-generic: public traits, +`&self`, named parameters, and `OperationResult`. Generic traits or methods, +associated items, custom lifetimes, inheritance, default method bodies, and +other receiver forms are rejected at their source spans. diff --git a/crates/wip-interface-macros/Cargo.toml b/crates/wip-interface-macros/Cargo.toml new file mode 100644 index 0000000..98d66ce --- /dev/null +++ b/crates/wip-interface-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "wip-interface-macros" +version = "0.1.0" +edition = "2024" +description = "Procedural macros for WIP interface declarations and DTO codecs" +license = "MIT OR Apache-2.0" + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "visit"] } diff --git a/crates/wip-interface-macros/src/lib.rs b/crates/wip-interface-macros/src/lib.rs new file mode 100644 index 0000000..a60e0c0 --- /dev/null +++ b/crates/wip-interface-macros/src/lib.rs @@ -0,0 +1,1343 @@ +//! Procedural macros backing the public `wip-interface` authoring facade. + +use std::collections::HashSet; + +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::{format_ident, quote}; +use syn::ext::IdentExt; +use syn::spanned::Spanned; +use syn::visit::{self, Visit}; +use syn::{ + Attribute, Data, DataEnum, DeriveInput, Error, Fields, FnArg, GenericArgument, Ident, + ItemTrait, Lit, Meta, Pat, PathArguments, ReturnType, TraitItem, TraitItemFn, Type, + parse_macro_input, +}; + +/// Declares a public Rust trait as a WIP interface. +#[proc_macro_attribute] +pub fn interface(attribute: TokenStream, item: TokenStream) -> TokenStream { + let attribute = TokenStream2::from(attribute); + let item = parse_macro_input!(item as ItemTrait); + + match expand_interface(attribute, item) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} + +/// Derives the WIP type declaration and logical value codec for a DTO. +#[proc_macro_derive(WipType)] +pub fn derive_wip_type(item: TokenStream) -> TokenStream { + let item = parse_macro_input!(item as DeriveInput); + + match expand_wip_type(item) { + Ok(tokens) => tokens.into(), + Err(error) => error.into_compile_error().into(), + } +} + +#[derive(Clone)] +struct Parameter { + ident: Ident, + name: String, + ty: Type, + optional: bool, + documentation: Option, +} + +struct Operation { + ident: Ident, + name: String, + arguments_ident: Ident, + parameters: Vec, + result_ty: Type, + unit_result: bool, + documentation: Option, + return_documentation: Option, +} + +#[derive(Clone)] +struct Documentation { + summary: String, + details: Option, +} + +fn expand_interface(attribute: TokenStream2, item: ItemTrait) -> syn::Result { + if !attribute.is_empty() { + return Err(Error::new_spanned( + attribute, + "the interface attribute does not accept arguments", + )); + } + + validate_interface_header(&item)?; + + let trait_ident = &item.ident; + let definition_ident = format_ident!("{}Definition", trait_ident, span = trait_ident.span()); + let trait_documentation = documentation(&item.attrs); + let mut operation_names = HashSet::new(); + let mut operations = Vec::new(); + + for trait_item in &item.items { + let method = match trait_item { + TraitItem::Fn(method) => method, + TraitItem::Type(item) => { + return Err(Error::new_spanned( + item, + "associated types are not supported in WIP interfaces", + )); + } + TraitItem::Const(item) => { + return Err(Error::new_spanned( + item, + "associated constants are not supported in WIP interfaces", + )); + } + other => { + return Err(Error::new_spanned( + other, + "only operation methods are supported in WIP interfaces", + )); + } + }; + + let operation = parse_operation(trait_ident, method)?; + if !operation_names.insert(operation.name.clone()) { + return Err(Error::new( + operation.ident.span(), + format!("duplicate operation name `{}`", operation.name), + )); + } + operations.push(operation); + } + + let argument_items = operations.iter().map(argument_item); + let operation_declarations = operations.iter().map(operation_declaration); + let declaration_collectors = operations.iter().flat_map(operation_collectors); + let dispatch_arms = operations.iter().map(dispatch_arm); + let trait_doc = documentation_tokens(trait_documentation.as_ref()); + let mut emitted_item = item.clone(); + strip_parameter_documentation(&mut emitted_item); + + Ok(quote! { + #emitted_item + + #[doc = concat!("Generated WIP interface definition for [`", stringify!(#trait_ident), "`].")] + pub struct #definition_ident; + + impl ::wip_interface::InterfaceDefinition for #definition_ident { + fn descriptor() -> ::wip_interface::wip_protocol::InterfaceDescriptor { + let mut declarations = ::std::vec::Vec::new(); + #(#declaration_collectors)* + + ::wip_interface::wip_protocol::InterfaceDescriptor { + name: ::std::string::String::from(stringify!(#trait_ident)), + documentation: #trait_doc, + declarations, + operations: ::std::vec![#(#operation_declarations),*], + } + } + } + + impl #definition_ident { + /// Builds the interface descriptor generated from the trait declaration. + pub fn descriptor() -> ::wip_interface::wip_protocol::InterfaceDescriptor { + ::descriptor() + } + + /// Dispatches one validated logical operation call to a typed implementation. + pub fn dispatch( + implementation: &T, + operation: &str, + arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, + ) -> ::std::result::Result< + ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::DispatchError, + > { + let descriptor = Self::descriptor(); + match operation { + #(#dispatch_arms)* + _ => ::std::result::Result::Err( + ::wip_interface::DispatchError::UnknownOperation( + ::std::string::String::from(operation), + ), + ), + } + } + } + + #(#argument_items)* + }) +} + +fn strip_parameter_documentation(item: &mut ItemTrait) { + for trait_item in &mut item.items { + let TraitItem::Fn(method) = trait_item else { + continue; + }; + for input in &mut method.sig.inputs { + if let FnArg::Typed(typed) = input { + typed + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + } + } + } +} + +fn validate_interface_header(item: &ItemTrait) -> syn::Result<()> { + if !matches!(item.vis, syn::Visibility::Public(_)) { + return Err(Error::new_spanned( + &item.vis, + "a WIP interface trait must be public", + )); + } + if item.unsafety.is_some() { + return Err(Error::new_spanned( + item.unsafety, + "unsafe interface traits are not supported", + )); + } + if item.auto_token.is_some() { + return Err(Error::new_spanned( + item.auto_token, + "auto traits are not supported as WIP interfaces", + )); + } + if !item.generics.params.is_empty() || item.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &item.generics, + "generic WIP interface traits are not supported", + )); + } + if item.colon_token.is_some() || !item.supertraits.is_empty() { + return Err(Error::new_spanned( + &item.supertraits, + "WIP interface trait inheritance is not supported", + )); + } + Ok(()) +} + +fn parse_operation(trait_ident: &Ident, method: &TraitItemFn) -> syn::Result { + let signature = &method.sig; + + if signature.constness.is_some() { + return Err(Error::new_spanned( + signature.constness, + "const operations are not supported", + )); + } + if signature.asyncness.is_some() { + return Err(Error::new_spanned( + signature.asyncness, + "async operations are not supported", + )); + } + if signature.unsafety.is_some() { + return Err(Error::new_spanned( + signature.unsafety, + "unsafe operations are not supported", + )); + } + if signature.abi.is_some() { + return Err(Error::new_spanned( + &signature.abi, + "extern operations are not supported", + )); + } + if signature.variadic.is_some() { + return Err(Error::new_spanned( + &signature.variadic, + "variadic operations are not supported", + )); + } + if !signature.generics.params.is_empty() || signature.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &signature.generics, + "generic operations are not supported", + )); + } + if let Some(default) = &method.default { + return Err(Error::new_spanned( + default, + "WIP operation methods must not have a body", + )); + } + + let mut inputs = signature.inputs.iter(); + let receiver = inputs.next().ok_or_else(|| { + Error::new( + signature.ident.span(), + "a WIP operation must have an `&self` receiver", + ) + })?; + validate_receiver(receiver)?; + + let mut parameters = Vec::new(); + let mut parameter_names = HashSet::new(); + for input in inputs { + let typed = match input { + FnArg::Typed(typed) => typed, + FnArg::Receiver(receiver) => { + return Err(Error::new_spanned( + receiver, + "the `&self` receiver must be the first operation argument", + )); + } + }; + let ident = match typed.pat.as_ref() { + Pat::Ident(pattern) + if pattern.by_ref.is_none() + && pattern.mutability.is_none() + && pattern.subpat.is_none() => + { + pattern.ident.clone() + } + pattern => { + return Err(Error::new_spanned( + pattern, + "operation parameters must be simple named parameters", + )); + } + }; + let name = source_name(&ident); + if !parameter_names.insert(name.clone()) { + return Err(Error::new( + ident.span(), + format!("duplicate operation parameter `{name}`"), + )); + } + let (optional, ty) = optional_type(typed.ty.as_ref(), "operation parameter")?; + validate_value_type(&ty, "operation parameter")?; + parameters.push(Parameter { + ident, + name, + ty, + optional, + documentation: documentation(&typed.attrs), + }); + } + + let result_ty = parse_operation_result(&signature.output)?; + let unit_result = is_unit(&result_ty); + if !unit_result { + validate_value_type(&result_ty, "operation return type")?; + } + + let name = source_name(&signature.ident); + let method_docs = doc_lines(&method.attrs); + let (operation_doc_lines, return_doc_lines) = split_returns_section(method_docs); + let pascal_name = pascal_case(&name); + let arguments_ident = format_ident!( + "{}{}Arguments", + trait_ident, + pascal_name, + span = signature.ident.span() + ); + + Ok(Operation { + ident: signature.ident.clone(), + name, + arguments_ident, + parameters, + result_ty, + unit_result, + documentation: documentation_from_lines(operation_doc_lines), + return_documentation: documentation_from_lines(return_doc_lines), + }) +} + +fn validate_receiver(input: &FnArg) -> syn::Result<()> { + let receiver = match input { + FnArg::Receiver(receiver) => receiver, + FnArg::Typed(typed) => { + return Err(Error::new_spanned( + typed, + "a WIP operation must start with an `&self` receiver", + )); + } + }; + + let reference = receiver.reference.as_ref(); + let is_plain_shared_reference = reference.is_some() + && receiver.mutability.is_none() + && receiver.colon_token.is_none() + && reference + .and_then(|(_, lifetime)| lifetime.as_ref()) + .is_none(); + if !is_plain_shared_reference { + return Err(Error::new_spanned( + receiver, + "the only supported receiver is exactly `&self`", + )); + } + Ok(()) +} + +fn parse_operation_result(output: &ReturnType) -> syn::Result { + let ty = match output { + ReturnType::Default => { + return Err(Error::new_spanned( + output, + "an operation must explicitly return `OperationResult`", + )); + } + ReturnType::Type(_, ty) => ty.as_ref(), + }; + + reject_custom_lifetimes(ty, "operation return type")?; + let path = match ty { + Type::Path(path) if path.qself.is_none() => &path.path, + _ => { + return Err(Error::new_spanned( + ty, + "an operation must explicitly return `OperationResult`", + )); + } + }; + let segment = path.segments.last().ok_or_else(|| { + Error::new_spanned( + ty, + "an operation must explicitly return `OperationResult`", + ) + })?; + if segment.ident != "OperationResult" { + return Err(Error::new_spanned( + ty, + "an operation must explicitly return `OperationResult`", + )); + } + let arguments = match &segment.arguments { + PathArguments::AngleBracketed(arguments) => arguments, + _ => { + return Err(Error::new_spanned( + segment, + "`OperationResult` must have exactly one application result type", + )); + } + }; + let mut types = arguments.args.iter().filter_map(|argument| match argument { + GenericArgument::Type(ty) => Some(ty), + _ => None, + }); + let result = types.next().cloned(); + if arguments.args.len() != 1 || result.is_none() || types.next().is_some() { + return Err(Error::new_spanned( + arguments, + "`OperationResult` must have exactly one application result type", + )); + } + let result = result.expect("checked above"); + if direct_option(&result)?.is_some() || contains_option(&result) { + return Err(Error::new_spanned( + result, + "`Option` is only supported directly on DTO fields and operation parameters", + )); + } + Ok(result) +} + +fn argument_item(operation: &Operation) -> TokenStream2 { + let arguments_ident = &operation.arguments_ident; + let fields = operation.parameters.iter().map(|parameter| { + let ident = ¶meter.ident; + let ty = parameter_rust_type(parameter); + quote!(pub #ident: #ty) + }); + let decoders = operation.parameters.iter().map(|parameter| { + let ident = ¶meter.ident; + let name = ¶meter.name; + let ty = ¶meter.ty; + if parameter.optional { + quote!(#ident: decoder.optional::<#ty>(#name)?) + } else { + quote!(#ident: decoder.required::<#ty>(#name)?) + } + }); + let result_ty = &operation.result_ty; + let encode_result = if operation.unit_result { + quote! { + let () = result; + ::std::result::Result::Ok(::std::option::Option::None) + } + } else { + quote! { + <#result_ty as ::wip_interface::WipType>::encode(result) + .map(::std::option::Option::Some) + } + }; + + quote! { + #[doc = concat!("Decoded arguments for `", stringify!(#arguments_ident), "`'s operation.")] + pub struct #arguments_ident { + #(#fields,)* + } + + impl #arguments_ident { + /// Decodes named logical values into typed operation arguments. + pub fn decode( + arguments: ::std::vec::Vec<::wip_interface::wip_protocol::NamedValue>, + ) -> ::std::result::Result { + let mut decoder = ::wip_interface::__private::RecordDecoder::new(arguments)?; + let decoded = Self { + #(#decoders,)* + }; + decoder.finish()?; + ::std::result::Result::Ok(decoded) + } + + /// Encodes this operation's application result. + pub fn encode_result( + result: #result_ty, + ) -> ::std::result::Result< + ::std::option::Option<::wip_interface::wip_protocol::Value>, + ::wip_interface::CodecError, + > { + #encode_result + } + } + } +} + +fn operation_declaration(operation: &Operation) -> TokenStream2 { + let name = &operation.name; + let documentation = documentation_tokens(operation.documentation.as_ref()); + let parameters = operation.parameters.iter().map(|parameter| { + let parameter_name = ¶meter.name; + let parameter_doc = documentation_tokens(parameter.documentation.as_ref()); + let ty = ¶meter.ty; + let optional = parameter.optional; + quote! { + ::wip_interface::wip_protocol::ParameterDeclaration { + name: ::std::string::String::from(#parameter_name), + documentation: #parameter_doc, + ty: <#ty as ::wip_interface::WipType>::type_expr(), + optional: #optional, + } + } + }); + let returns = if operation.unit_result { + quote!(::std::option::Option::None) + } else { + let result_ty = &operation.result_ty; + let return_doc = documentation_tokens(operation.return_documentation.as_ref()); + quote! { + ::std::option::Option::Some(::wip_interface::wip_protocol::ReturnDeclaration { + documentation: #return_doc, + ty: <#result_ty as ::wip_interface::WipType>::type_expr(), + }) + } + }; + + quote! { + ::wip_interface::wip_protocol::OperationDeclaration { + name: ::std::string::String::from(#name), + documentation: #documentation, + parameters: ::std::vec![#(#parameters),*], + returns: #returns, + } + } +} + +fn operation_collectors(operation: &Operation) -> Vec { + let mut collectors = operation + .parameters + .iter() + .map(|parameter| { + let ty = ¶meter.ty; + quote!(<#ty as ::wip_interface::WipType>::collect_declarations(&mut declarations);) + }) + .collect::>(); + if !operation.unit_result { + let ty = &operation.result_ty; + collectors.push( + quote!(<#ty as ::wip_interface::WipType>::collect_declarations(&mut declarations);), + ); + } + collectors +} + +fn dispatch_arm(operation: &Operation) -> TokenStream2 { + let operation_name = &operation.name; + let method_ident = &operation.ident; + let arguments_ident = &operation.arguments_ident; + let arguments = operation.parameters.iter().map(|parameter| { + let ident = ¶meter.ident; + quote!(decoded.#ident) + }); + + quote! { + #operation_name => { + ::wip_interface::__private::validate_arguments( + &descriptor, + #operation_name, + &arguments, + ) + .map_err(::wip_interface::DispatchError::InvalidArguments)?; + let decoded = #arguments_ident::decode(arguments) + .map_err(::wip_interface::DispatchError::Decode)?; + let result = implementation.#method_ident(#(#arguments),*) + .map_err(::wip_interface::DispatchError::Host)?; + let encoded = #arguments_ident::encode_result(result) + .map_err(::wip_interface::DispatchError::Encode)?; + descriptor + .validate_return(#operation_name, encoded.as_ref()) + .map_err(::wip_interface::DispatchError::InvalidResult)?; + ::std::result::Result::Ok(encoded) + } + } +} + +fn parameter_rust_type(parameter: &Parameter) -> TokenStream2 { + let ty = ¶meter.ty; + if parameter.optional { + quote!(::std::option::Option<#ty>) + } else { + quote!(#ty) + } +} + +fn expand_wip_type(item: DeriveInput) -> syn::Result { + if !item.generics.params.is_empty() || item.generics.where_clause.is_some() { + return Err(Error::new_spanned( + &item.generics, + "generic DTOs are not supported by `WipType`", + )); + } + + let ident = &item.ident; + let type_name = source_name(ident); + let type_documentation = documentation(&item.attrs); + + let implementation = match &item.data { + Data::Struct(data) => { + let fields = match &data.fields { + Fields::Named(fields) => &fields.named, + Fields::Unnamed(fields) => { + return Err(Error::new_spanned( + fields, + "`WipType` supports only structs with named fields", + )); + } + Fields::Unit => { + return Err(Error::new_spanned( + &item.ident, + "unit structs are not supported by `WipType`", + )); + } + }; + derive_record(ident, &type_name, type_documentation.as_ref(), fields)? + } + Data::Enum(data) => derive_enum(ident, &type_name, type_documentation.as_ref(), data)?, + Data::Union(data) => { + return Err(Error::new_spanned( + data.union_token, + "Rust unions are not supported by `WipType`", + )); + } + }; + + Ok(implementation) +} + +fn derive_record( + ident: &Ident, + type_name: &str, + type_documentation: Option<&Documentation>, + fields: &syn::punctuated::Punctuated, +) -> syn::Result { + let mut parsed_fields = Vec::new(); + let mut names = HashSet::new(); + for field in fields { + let field_ident = field.ident.clone().expect("named fields have identifiers"); + let name = source_name(&field_ident); + if !names.insert(name.clone()) { + return Err(Error::new( + field_ident.span(), + format!("duplicate record field `{name}`"), + )); + } + let (optional, ty) = optional_type(&field.ty, "DTO field")?; + validate_value_type(&ty, "DTO field")?; + parsed_fields.push(Parameter { + ident: field_ident, + name, + ty, + optional, + documentation: documentation(&field.attrs), + }); + } + + let declaration_doc = documentation_tokens(type_documentation); + let field_declarations = parsed_fields.iter().map(|field| { + let name = &field.name; + let documentation = documentation_tokens(field.documentation.as_ref()); + let ty = &field.ty; + let optional = field.optional; + quote! { + ::wip_interface::wip_protocol::FieldDeclaration { + name: ::std::string::String::from(#name), + documentation: #documentation, + ty: <#ty as ::wip_interface::WipType>::type_expr(), + optional: #optional, + } + } + }); + let dependency_collectors = parsed_fields.iter().map(|field| { + let ty = &field.ty; + quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);) + }); + let destructured_fields = parsed_fields.iter().map(|field| &field.ident); + let encoders = parsed_fields.iter().map(|field| { + let field_ident = &field.ident; + let field_name = &field.name; + let ty = &field.ty; + if field.optional { + quote! { + if let ::std::option::Option::Some(value) = #field_ident { + fields.push(::wip_interface::wip_protocol::NamedValue { + name: ::std::string::String::from(#field_name), + value: <#ty as ::wip_interface::WipType>::encode(value)?, + }); + } + } + } else { + quote! { + fields.push(::wip_interface::wip_protocol::NamedValue { + name: ::std::string::String::from(#field_name), + value: <#ty as ::wip_interface::WipType>::encode(#field_ident)?, + }); + } + } + }); + let decoders = parsed_fields.iter().map(|field| { + let field_ident = &field.ident; + let field_name = &field.name; + let ty = &field.ty; + if field.optional { + quote!(#field_ident: decoder.optional::<#ty>(#field_name)?) + } else { + quote!(#field_ident: decoder.required::<#ty>(#field_name)?) + } + }); + + Ok(quote! { + impl ::wip_interface::WipType for #ident { + fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { + ::wip_interface::wip_protocol::TypeExpr::Named( + ::std::string::String::from(#type_name), + ) + } + + fn collect_declarations( + declarations: &mut ::std::vec::Vec< + ::wip_interface::wip_protocol::TypeDeclaration, + >, + ) { + let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Record( + ::wip_interface::wip_protocol::RecordDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + fields: ::std::vec![#(#field_declarations),*], + }, + ); + if ::wip_interface::__private::register_declaration(declarations, declaration) { + #(#dependency_collectors)* + } + } + + fn encode( + self, + ) -> ::std::result::Result< + ::wip_interface::wip_protocol::Value, + ::wip_interface::CodecError, + > { + let Self { #(#destructured_fields),* } = self; + let mut fields = ::std::vec::Vec::new(); + #(#encoders)* + ::std::result::Result::Ok( + ::wip_interface::wip_protocol::Value::Record(fields), + ) + } + + fn decode( + value: ::wip_interface::wip_protocol::Value, + ) -> ::std::result::Result { + match value { + ::wip_interface::wip_protocol::Value::Record(fields) => { + let mut decoder = ::wip_interface::__private::RecordDecoder::new(fields)?; + let decoded = Self { + #(#decoders,)* + }; + decoder.finish()?; + ::std::result::Result::Ok(decoded) + } + actual => ::std::result::Result::Err( + ::wip_interface::__private::decode_type_mismatch("record", actual), + ), + } + } + } + }) +} + +fn derive_enum( + ident: &Ident, + type_name: &str, + type_documentation: Option<&Documentation>, + data: &DataEnum, +) -> syn::Result { + for variant in &data.variants { + if let Some((_, discriminant)) = &variant.discriminant { + return Err(Error::new_spanned( + discriminant, + "explicit enum discriminants are not supported by `WipType`", + )); + } + } + + let all_unit = data + .variants + .iter() + .all(|variant| matches!(variant.fields, Fields::Unit)); + let all_payload = !data.variants.is_empty() + && data.variants.iter().all(|variant| { + matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) + }); + + if all_unit { + derive_unit_enum(ident, type_name, type_documentation, data) + } else if all_payload { + derive_union_enum(ident, type_name, type_documentation, data) + } else { + let span = data + .variants + .iter() + .find(|variant| { + !matches!(variant.fields, Fields::Unit) + && !matches!(&variant.fields, Fields::Unnamed(fields) if fields.unnamed.len() == 1) + }) + .map_or_else(|| data.enum_token.span, Spanned::span); + Err(Error::new( + span, + "a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported", + )) + } +} + +fn derive_unit_enum( + ident: &Ident, + type_name: &str, + type_documentation: Option<&Documentation>, + data: &DataEnum, +) -> syn::Result { + let declaration_doc = documentation_tokens(type_documentation); + let declarations = data.variants.iter().map(|variant| { + let name = source_name(&variant.ident); + let documentation = documentation_tokens(documentation(&variant.attrs).as_ref()); + quote! { + ::wip_interface::wip_protocol::EnumVariantDeclaration { + name: ::std::string::String::from(#name), + documentation: #documentation, + } + } + }); + let encoders = data.variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let name = source_name(variant_ident); + quote!(Self::#variant_ident => #name) + }); + let decoders = data.variants.iter().map(|variant| { + let variant_ident = &variant.ident; + let name = source_name(variant_ident); + quote!(#name => ::std::result::Result::Ok(Self::#variant_ident)) + }); + + Ok(quote! { + impl ::wip_interface::WipType for #ident { + fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { + ::wip_interface::wip_protocol::TypeExpr::Named( + ::std::string::String::from(#type_name), + ) + } + + fn collect_declarations( + declarations: &mut ::std::vec::Vec< + ::wip_interface::wip_protocol::TypeDeclaration, + >, + ) { + let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Enum( + ::wip_interface::wip_protocol::EnumDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + variants: ::std::vec![#(#declarations),*], + }, + ); + let _ = ::wip_interface::__private::register_declaration( + declarations, + declaration, + ); + } + + fn encode( + self, + ) -> ::std::result::Result< + ::wip_interface::wip_protocol::Value, + ::wip_interface::CodecError, + > { + let variant = match self { + #(#encoders,)* + }; + ::std::result::Result::Ok( + ::wip_interface::wip_protocol::Value::Enum( + ::std::string::String::from(variant), + ), + ) + } + + fn decode( + value: ::wip_interface::wip_protocol::Value, + ) -> ::std::result::Result { + match value { + ::wip_interface::wip_protocol::Value::Enum(variant) => { + match variant.as_str() { + #(#decoders,)* + _ => ::std::result::Result::Err( + ::wip_interface::__private::decode_unknown_variant( + #type_name, + variant, + ), + ), + } + } + actual => ::std::result::Result::Err( + ::wip_interface::__private::decode_type_mismatch("enum", actual), + ), + } + } + } + }) +} + +fn derive_union_enum( + ident: &Ident, + type_name: &str, + type_documentation: Option<&Documentation>, + data: &DataEnum, +) -> syn::Result { + struct Variant<'a> { + ident: &'a Ident, + name: String, + ty: &'a Type, + documentation: Option, + } + + let mut variants = Vec::new(); + for variant in &data.variants { + let Fields::Unnamed(fields) = &variant.fields else { + unreachable!("enum shape was checked before union generation"); + }; + let field = fields + .unnamed + .first() + .expect("union variants have one field"); + if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { + return Err(Error::new_spanned( + &field.ty, + "`Option` is only supported directly on DTO fields and operation parameters", + )); + } + validate_value_type(&field.ty, "union variant payload")?; + variants.push(Variant { + ident: &variant.ident, + name: source_name(&variant.ident), + ty: &field.ty, + documentation: documentation(&variant.attrs), + }); + } + + let declaration_doc = documentation_tokens(type_documentation); + let declarations = variants.iter().map(|variant| { + let name = &variant.name; + let documentation = documentation_tokens(variant.documentation.as_ref()); + let ty = variant.ty; + quote! { + ::wip_interface::wip_protocol::UnionVariantDeclaration { + name: ::std::string::String::from(#name), + documentation: #documentation, + ty: <#ty as ::wip_interface::WipType>::type_expr(), + } + } + }); + let dependency_collectors = variants.iter().map(|variant| { + let ty = variant.ty; + quote!(<#ty as ::wip_interface::WipType>::collect_declarations(declarations);) + }); + let encoders = variants.iter().map(|variant| { + let variant_ident = variant.ident; + let name = &variant.name; + let ty = variant.ty; + quote! { + Self::#variant_ident(value) => { + ::wip_interface::wip_protocol::Value::Union { + variant: ::std::string::String::from(#name), + value: ::std::boxed::Box::new( + <#ty as ::wip_interface::WipType>::encode(value)?, + ), + } + } + } + }); + let decoders = variants.iter().map(|variant| { + let variant_ident = variant.ident; + let name = &variant.name; + let ty = variant.ty; + quote! { + #name => <#ty as ::wip_interface::WipType>::decode(*value) + .map(Self::#variant_ident) + } + }); + + Ok(quote! { + impl ::wip_interface::WipType for #ident { + fn type_expr() -> ::wip_interface::wip_protocol::TypeExpr { + ::wip_interface::wip_protocol::TypeExpr::Named( + ::std::string::String::from(#type_name), + ) + } + + fn collect_declarations( + declarations: &mut ::std::vec::Vec< + ::wip_interface::wip_protocol::TypeDeclaration, + >, + ) { + let declaration = ::wip_interface::wip_protocol::TypeDeclaration::Union( + ::wip_interface::wip_protocol::UnionDeclaration { + name: ::std::string::String::from(#type_name), + documentation: #declaration_doc, + variants: ::std::vec![#(#declarations),*], + }, + ); + if ::wip_interface::__private::register_declaration(declarations, declaration) { + #(#dependency_collectors)* + } + } + + fn encode( + self, + ) -> ::std::result::Result< + ::wip_interface::wip_protocol::Value, + ::wip_interface::CodecError, + > { + let value = match self { + #(#encoders,)* + }; + ::std::result::Result::Ok(value) + } + + fn decode( + value: ::wip_interface::wip_protocol::Value, + ) -> ::std::result::Result { + match value { + ::wip_interface::wip_protocol::Value::Union { variant, value } => { + match variant.as_str() { + #(#decoders,)* + _ => ::std::result::Result::Err( + ::wip_interface::__private::decode_unknown_variant( + #type_name, + variant, + ), + ), + } + } + actual => ::std::result::Result::Err( + ::wip_interface::__private::decode_type_mismatch("union", actual), + ), + } + } + } + }) +} + +fn optional_type(ty: &Type, context: &str) -> syn::Result<(bool, Type)> { + if let Some(inner) = direct_option(ty)? { + if contains_option(&inner) { + return Err(Error::new_spanned( + inner, + format!("nested `Option` is not supported for {context}"), + )); + } + Ok((true, inner)) + } else { + if contains_option(ty) { + return Err(Error::new_spanned( + ty, + format!("`Option` must appear directly as the {context} type"), + )); + } + Ok((false, ty.clone())) + } +} + +fn direct_option(ty: &Type) -> syn::Result> { + let Type::Path(path) = ty else { + return Ok(None); + }; + if path.qself.is_some() { + return Ok(None); + } + let Some(segment) = path.path.segments.last() else { + return Ok(None); + }; + if segment.ident != "Option" { + return Ok(None); + } + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return Err(Error::new_spanned( + segment, + "`Option` must have exactly one type argument", + )); + }; + if arguments.args.len() != 1 { + return Err(Error::new_spanned( + arguments, + "`Option` must have exactly one type argument", + )); + } + match arguments.args.first() { + Some(GenericArgument::Type(inner)) => Ok(Some(inner.clone())), + _ => Err(Error::new_spanned( + arguments, + "`Option` must have exactly one type argument", + )), + } +} + +fn validate_value_type(ty: &Type, context: &str) -> syn::Result<()> { + reject_custom_lifetimes(ty, context)?; + if let Some(span) = first_reference(ty) { + return Err(Error::new( + span, + format!("borrowed types are not supported as a WIP {context}"), + )); + } + if let Some(span) = first_unit(ty) { + return Err(Error::new( + span, + format!( + "`()` is only supported as an operation application return, not as a {context}" + ), + )); + } + if contains_option(ty) { + return Err(Error::new_spanned( + ty, + "`Option` is only supported directly on DTO fields and operation parameters", + )); + } + Ok(()) +} + +fn reject_custom_lifetimes(ty: &Type, context: &str) -> syn::Result<()> { + struct Finder(Option); + impl<'ast> Visit<'ast> for Finder { + fn visit_lifetime(&mut self, lifetime: &'ast syn::Lifetime) { + if self.0.is_none() { + self.0 = Some(lifetime.span()); + } + } + } + + let mut finder = Finder(None); + finder.visit_type(ty); + if let Some(span) = finder.0 { + Err(Error::new( + span, + format!("custom lifetimes are not supported in a WIP {context}"), + )) + } else { + Ok(()) + } +} + +fn contains_option(ty: &Type) -> bool { + struct Finder(bool); + impl<'ast> Visit<'ast> for Finder { + fn visit_type_path(&mut self, path: &'ast syn::TypePath) { + if path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "Option") + { + self.0 = true; + } + visit::visit_type_path(self, path); + } + } + + let mut finder = Finder(false); + finder.visit_type(ty); + finder.0 +} + +fn first_reference(ty: &Type) -> Option { + struct Finder(Option); + impl<'ast> Visit<'ast> for Finder { + fn visit_type_reference(&mut self, reference: &'ast syn::TypeReference) { + if self.0.is_none() { + self.0 = Some(reference.span()); + } + visit::visit_type_reference(self, reference); + } + } + + let mut finder = Finder(None); + finder.visit_type(ty); + finder.0 +} + +fn first_unit(ty: &Type) -> Option { + struct Finder(Option); + impl<'ast> Visit<'ast> for Finder { + fn visit_type_tuple(&mut self, tuple: &'ast syn::TypeTuple) { + if tuple.elems.is_empty() && self.0.is_none() { + self.0 = Some(tuple.span()); + } + visit::visit_type_tuple(self, tuple); + } + } + + let mut finder = Finder(None); + finder.visit_type(ty); + finder.0 +} + +fn is_unit(ty: &Type) -> bool { + matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) +} + +fn source_name(ident: &Ident) -> String { + ident.unraw().to_string() +} + +fn pascal_case(name: &str) -> String { + let mut result = String::new(); + let mut uppercase = true; + for character in name.chars() { + if character == '_' { + uppercase = true; + } else if uppercase { + result.extend(character.to_uppercase()); + uppercase = false; + } else { + result.push(character); + } + } + result +} + +fn documentation(attributes: &[Attribute]) -> Option { + documentation_from_lines(doc_lines(attributes)) +} + +fn doc_lines(attributes: &[Attribute]) -> Vec { + attributes + .iter() + .filter_map(|attribute| { + if !attribute.path().is_ident("doc") { + return None; + } + match &attribute.meta { + Meta::NameValue(name_value) => match &name_value.value { + syn::Expr::Lit(expression) => match &expression.lit { + Lit::Str(value) => { + let value = value.value(); + Some(value.strip_prefix(' ').unwrap_or(&value).to_owned()) + } + _ => None, + }, + _ => None, + }, + _ => None, + } + }) + .collect() +} + +fn split_returns_section(lines: Vec) -> (Vec, Vec) { + let Some(start) = lines.iter().position(|line| line.trim() == "# Returns") else { + return (lines, Vec::new()); + }; + let end = lines[start + 1..] + .iter() + .position(|line| line.trim_start().starts_with("# ")) + .map_or(lines.len(), |offset| start + 1 + offset); + + let mut operation = lines[..start].to_vec(); + operation.extend_from_slice(&lines[end..]); + (operation, lines[start + 1..end].to_vec()) +} + +fn documentation_from_lines(mut lines: Vec) -> Option { + while lines.first().is_some_and(|line| line.trim().is_empty()) { + lines.remove(0); + } + while lines.last().is_some_and(|line| line.trim().is_empty()) { + lines.pop(); + } + if lines.is_empty() { + return None; + } + + let split = lines + .iter() + .position(|line| line.trim().is_empty()) + .unwrap_or(lines.len()); + let summary = lines[..split] + .iter() + .map(|line| line.trim()) + .collect::>() + .join(" "); + let mut details_lines = if split < lines.len() { + lines[split + 1..].to_vec() + } else { + Vec::new() + }; + while details_lines + .first() + .is_some_and(|line| line.trim().is_empty()) + { + details_lines.remove(0); + } + while details_lines + .last() + .is_some_and(|line| line.trim().is_empty()) + { + details_lines.pop(); + } + let details = (!details_lines.is_empty()).then(|| details_lines.join("\n")); + + Some(Documentation { summary, details }) +} + +fn documentation_tokens(documentation: Option<&Documentation>) -> TokenStream2 { + match documentation { + Some(documentation) => { + let summary = &documentation.summary; + match &documentation.details { + Some(details) => quote! { + ::wip_interface::__private::documentation(#summary, ::std::option::Option::Some(#details)) + }, + None => quote! { + ::wip_interface::__private::documentation(#summary, ::std::option::Option::None) + }, + } + } + None => quote! { + ::wip_interface::__private::documentation("", ::std::option::Option::None) + }, + } +} diff --git a/crates/wip-interface/Cargo.toml b/crates/wip-interface/Cargo.toml new file mode 100644 index 0000000..a754279 --- /dev/null +++ b/crates/wip-interface/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "wip-interface" +version = "0.1.0" +edition = "2024" +description = "Public Rust authoring API for WIP interfaces and operation value codecs" +license = "MIT OR Apache-2.0" + +[dependencies] +wip-interface-macros = { path = "../wip-interface-macros" } +wip-protocol = { path = "../wip-protocol" } + +[dev-dependencies] +trybuild = "1" diff --git a/crates/wip-interface/src/lib.rs b/crates/wip-interface/src/lib.rs new file mode 100644 index 0000000..6602160 --- /dev/null +++ b/crates/wip-interface/src/lib.rs @@ -0,0 +1,661 @@ +//! Public authoring API for Web Interface Protocol (WIP) interfaces. +//! +//! Add `wip-interface` to an application and import it under the short name +//! used by the attribute examples: +//! +//! ``` +//! use wip_interface as wip; +//! +//! #[derive(Debug, PartialEq, wip::WipType)] +//! struct Greeting { +//! /// Text shown to the recipient. +//! message: String, +//! } +//! +//! #[wip::interface] +//! pub trait Greeter { +//! /// Builds a greeting. +//! /// +//! /// # Returns +//! /// The generated greeting. +//! fn greet(&self, /// Person to greet. +//! name: String) -> wip::OperationResult; +//! } +//! ``` +//! +//! The attribute and derive are re-exported by this facade; applications do +//! not depend on the implementation-only `wip-interface-macros` crate. +//! +//! # Rust to protocol type mapping +//! +//! | Rust type | Protocol type/value | +//! | --- | --- | +//! | `()` as an operation result | no return declaration / no value | +//! | `bool` | `Boolean` | +//! | signed and unsigned integer primitives | JavaScript-lossless `Integer` | +//! | `f32`, `f64` | finite `Number` | +//! | `String` | `String` | +//! | [`Bytes`] | `Bytes` | +//! | [`wip_protocol::JsonValue`] | `Json` | +//! | [`Entry`] | `Entry` | +//! | `Vec` | `List` | +//! | a named-field struct deriving [`WipType`] | named record | +//! | an all-unit enum deriving [`WipType`] | named enum | +//! | an all-newtype enum deriving [`WipType`] | named tagged union | +//! +//! `Option` is supported only directly as a record field or operation +//! parameter. It means omission, not a null value. A domain failure belongs in +//! an application return enum/union and is returned as `Ok(value)`. +//! [`HostError`] is reserved for host/runtime failure, so the required +//! [`OperationResult`] convention keeps those two failure classes distinct. + +#![deny(missing_docs)] + +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +pub use wip_interface_macros::{WipType, interface}; +pub use wip_protocol; +pub use wip_protocol::{ + Documentation, EnumDeclaration, EnumVariantDeclaration, FieldDeclaration, InterfaceDescriptor, + JsonMember, JsonValue, MAX_SAFE_INTEGER, MIN_SAFE_INTEGER, NamedValue, OperationDeclaration, + ParameterDeclaration, RecordDeclaration, ReturnDeclaration, TypeDeclaration, TypeExpr, + UnionDeclaration, UnionVariantDeclaration, ValidationError, Value, +}; + +/// Converts one Rust application type to and from its protocol representation. +/// +/// Implementations must describe the same shape accepted by [`WipType::decode`] +/// and produced by [`WipType::encode`]. Prefer deriving this trait for named +/// DTOs. Its declaration-collection method exists for generated interface code +/// and normally should not be called by applications. +pub trait WipType: Sized { + /// Returns the protocol type expression for this Rust type. + fn type_expr() -> TypeExpr; + + /// Adds named declarations reachable from this type to `declarations`. + /// + /// Implementations must be cycle-safe and preserve declaration order. + fn collect_declarations(declarations: &mut Vec); + + /// Encodes this value into the transport-independent protocol value model. + fn encode(self) -> Result; + + /// Decodes a previously validated protocol value. + fn decode(value: Value) -> Result; +} + +/// Static metadata implemented by every companion generated by +/// [`interface`]. +pub trait InterfaceDefinition { + /// Builds the interface descriptor solely from its trait declaration. + fn descriptor() -> InterfaceDescriptor; +} + +/// Result convention required for generated interface operations. +/// +/// `Ok(T)` is an application result, including domain-failure union cases. +/// `Err(HostError)` means the host or runtime could not perform the operation. +pub type OperationResult = Result; + +/// A host/runtime failure, kept separate from the operation's application +/// return type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostError { + message: String, +} + +impl HostError { + /// Creates a host failure with a human-readable diagnostic. + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Returns the diagnostic message. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl Display for HostError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for HostError {} + +/// A failure while converting between a Rust value and a protocol [`Value`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodecError { + path: Vec, + message: String, +} + +impl CodecError { + /// Creates a codec failure at the current value. + #[must_use] + pub fn new(message: impl Into) -> Self { + Self { + path: Vec::new(), + message: message.into(), + } + } + + /// Creates a structural-kind mismatch diagnostic. + #[must_use] + pub fn expected(expected: &str, actual: &Value) -> Self { + Self::new(format!("expected {expected}, found {:?}", actual.kind())) + } + + /// Prepends a field, variant, or collection position to the error path. + #[must_use] + pub fn at(mut self, segment: impl Into) -> Self { + self.path.insert(0, segment.into()); + self + } + + /// Returns the path from the decoded root to the failing value. + #[must_use] + pub fn path(&self) -> &[String] { + &self.path + } + + /// Returns the human-readable reason for the failure. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl Display for CodecError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + if self.path.is_empty() { + write!(formatter, "WIP codec error: {}", self.message) + } else { + write!( + formatter, + "WIP codec error at {}: {}", + self.path.join("."), + self.message + ) + } + } +} + +impl Error for CodecError {} + +/// A failure produced by macro-generated typed operation dispatch. +#[derive(Debug)] +pub enum DispatchError { + /// The requested operation is not declared by this interface. + UnknownOperation(String), + /// Protocol validation rejected the named arguments. + InvalidArguments(wip_protocol::ValidationError), + /// A validated value could not be decoded to its Rust type. + Decode(CodecError), + /// The implementation reported a host/runtime failure. + Host(HostError), + /// The Rust result could not be encoded. + Encode(CodecError), + /// The encoded result did not match the generated declaration. + InvalidResult(wip_protocol::ValidationError), +} + +impl Display for DispatchError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::UnknownOperation(operation) => { + write!(formatter, "unknown operation `{operation}`") + } + Self::InvalidArguments(error) => write!(formatter, "invalid arguments: {error}"), + Self::Decode(error) => write!(formatter, "argument decoding failed: {error}"), + Self::Host(error) => write!(formatter, "host operation failed: {error}"), + Self::Encode(error) => write!(formatter, "result encoding failed: {error}"), + Self::InvalidResult(error) => write!(formatter, "invalid operation result: {error}"), + } + } +} + +impl Error for DispatchError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::UnknownOperation(_) => None, + Self::InvalidArguments(error) | Self::InvalidResult(error) => Some(error), + Self::Decode(error) | Self::Encode(error) => Some(error), + Self::Host(error) => Some(error), + } + } +} + +/// An explicitly byte-oriented sequence. +/// +/// Use this wrapper when the protocol type must be `Bytes`; `Vec` always +/// denotes a protocol list, including `Vec`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Bytes(pub Vec); + +impl From> for Bytes { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl From for Vec { + fn from(value: Bytes) -> Self { + value.0 + } +} + +/// A typed protocol key/value entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entry { + /// Entry key. + pub key: K, + /// Entry value. + pub value: V, +} + +impl WipType for bool { + fn type_expr() -> TypeExpr { + TypeExpr::Boolean + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + Ok(Value::Boolean(self)) + } + + fn decode(value: Value) -> Result { + match value { + Value::Boolean(value) => Ok(value), + other => Err(CodecError::expected("boolean", &other)), + } + } +} + +macro_rules! integer_codec { + ($($ty:ty),+ $(,)?) => { + $( + impl WipType for $ty { + fn type_expr() -> TypeExpr { + TypeExpr::Integer + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + let value = i64::try_from(self).map_err(|_| { + CodecError::new("integer is outside the protocol i64 range") + })?; + ensure_safe_integer(value)?; + Ok(Value::Integer(value)) + } + + fn decode(value: Value) -> Result { + let Value::Integer(value) = value else { + return Err(CodecError::expected("integer", &value)); + }; + ensure_safe_integer(value)?; + Self::try_from(value).map_err(|_| { + CodecError::new(concat!("integer does not fit Rust `", stringify!($ty), "`")) + }) + } + } + )+ + }; +} + +integer_codec!( + i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize +); + +fn ensure_safe_integer(value: i64) -> Result<(), CodecError> { + if (MIN_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&value) { + Ok(()) + } else { + Err(CodecError::new(format!( + "integer {value} is outside the JavaScript-lossless protocol range" + ))) + } +} + +impl WipType for f64 { + fn type_expr() -> TypeExpr { + TypeExpr::Number + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + ensure_finite(self)?; + Ok(Value::Number(self)) + } + + fn decode(value: Value) -> Result { + match value { + Value::Number(value) => { + ensure_finite(value)?; + Ok(value) + } + other => Err(CodecError::expected("number", &other)), + } + } +} + +impl WipType for f32 { + fn type_expr() -> TypeExpr { + TypeExpr::Number + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + let value = f64::from(self); + ensure_finite(value)?; + Ok(Value::Number(value)) + } + + fn decode(value: Value) -> Result { + let value = f64::decode(value)?; + let narrowed = value as Self; + if narrowed.is_finite() { + Ok(narrowed) + } else { + Err(CodecError::new("number does not fit finite Rust `f32`")) + } + } +} + +fn ensure_finite(value: f64) -> Result<(), CodecError> { + if value.is_finite() { + Ok(()) + } else { + Err(CodecError::new("protocol numbers must be finite")) + } +} + +impl WipType for String { + fn type_expr() -> TypeExpr { + TypeExpr::String + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + Ok(Value::String(self)) + } + + fn decode(value: Value) -> Result { + match value { + Value::String(value) => Ok(value), + other => Err(CodecError::expected("string", &other)), + } + } +} + +impl WipType for Bytes { + fn type_expr() -> TypeExpr { + TypeExpr::Bytes + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + Ok(Value::Bytes(self.0)) + } + + fn decode(value: Value) -> Result { + match value { + Value::Bytes(value) => Ok(Self(value)), + other => Err(CodecError::expected("bytes", &other)), + } + } +} + +impl WipType for JsonValue { + fn type_expr() -> TypeExpr { + TypeExpr::Json + } + + fn collect_declarations(_: &mut Vec) {} + + fn encode(self) -> Result { + validate_json(&self)?; + Ok(Value::Json(self)) + } + + fn decode(value: Value) -> Result { + match value { + Value::Json(value) => { + validate_json(&value)?; + Ok(value) + } + other => Err(CodecError::expected("json", &other)), + } + } +} + +fn validate_json(value: &JsonValue) -> Result<(), CodecError> { + match value { + JsonValue::Integer(value) => ensure_safe_integer(*value), + JsonValue::Number(value) => ensure_finite(*value), + JsonValue::Array(values) => { + for (index, value) in values.iter().enumerate() { + validate_json(value).map_err(|error| error.at(index.to_string()))?; + } + Ok(()) + } + JsonValue::Object(members) => { + let mut names = std::collections::HashSet::new(); + for member in members { + if !names.insert(member.name.as_str()) { + return Err(CodecError::new(format!( + "duplicate JSON member `{}`", + member.name + )) + .at(member.name.clone())); + } + validate_json(&member.value).map_err(|error| error.at(member.name.clone()))?; + } + Ok(()) + } + JsonValue::Null | JsonValue::Boolean(_) | JsonValue::String(_) => Ok(()), + } +} + +impl WipType for Vec { + fn type_expr() -> TypeExpr { + TypeExpr::List(Box::new(T::type_expr())) + } + + fn collect_declarations(declarations: &mut Vec) { + T::collect_declarations(declarations); + } + + fn encode(self) -> Result { + self.into_iter() + .enumerate() + .map(|(index, value)| value.encode().map_err(|error| error.at(index.to_string()))) + .collect::, _>>() + .map(Value::List) + } + + fn decode(value: Value) -> Result { + let Value::List(values) = value else { + return Err(CodecError::expected("list", &value)); + }; + values + .into_iter() + .enumerate() + .map(|(index, value)| T::decode(value).map_err(|error| error.at(index.to_string()))) + .collect() + } +} + +impl WipType for Entry { + fn type_expr() -> TypeExpr { + TypeExpr::Entry { + key: Box::new(K::type_expr()), + value: Box::new(V::type_expr()), + } + } + + fn collect_declarations(declarations: &mut Vec) { + K::collect_declarations(declarations); + V::collect_declarations(declarations); + } + + fn encode(self) -> Result { + Ok(Value::Entry { + key: Box::new(self.key.encode().map_err(|error| error.at("key"))?), + value: Box::new(self.value.encode().map_err(|error| error.at("value"))?), + }) + } + + fn decode(value: Value) -> Result { + let Value::Entry { key, value } = value else { + return Err(CodecError::expected("entry", &value)); + }; + Ok(Self { + key: K::decode(*key).map_err(|error| error.at("key"))?, + value: V::decode(*value).map_err(|error| error.at("value"))?, + }) + } +} + +/// Implementation details used by code emitted from the facade's macros. +/// +/// These APIs are public only because macro expansions execute in the +/// application crate. They are excluded from normal generated documentation +/// and are not intended for direct use. +#[doc(hidden)] +pub mod __private { + use std::collections::HashSet; + + use wip_protocol::{ + InterfaceDescriptor, NamedValue, ObjectId, OperationCall, OperationTarget, TypeDeclaration, + ValidationError, Value, + }; + + use crate::{CodecError, Documentation, WipType}; + + /// Builds optional descriptor documentation from generated string literals. + pub fn documentation(summary: &str, details: Option<&str>) -> Option { + if summary.is_empty() && details.is_none() { + None + } else { + Some(Documentation { + summary: summary.into(), + details: details.map(str::to_owned), + }) + } + } + + /// Registers one declaration and reports whether its dependencies should + /// be traversed. Identical declarations are deduplicated; a conflicting + /// declaration with the same name is retained for protocol validation. + pub fn register_declaration( + declarations: &mut Vec, + declaration: TypeDeclaration, + ) -> bool { + if declarations.iter().any(|existing| existing == &declaration) { + false + } else { + declarations.push(declaration); + true + } + } + + /// Validates named arguments against one generated operation declaration. + pub fn validate_arguments( + descriptor: &InterfaceDescriptor, + operation: &str, + arguments: &[NamedValue], + ) -> Result<(), ValidationError> { + descriptor.validate_call(&OperationCall { + target: OperationTarget { + object: ObjectId("".into()), + interface: descriptor.name.clone(), + operation: operation.into(), + }, + arguments: arguments.to_vec(), + }) + } + + /// Builds a codec error for a generated structural mismatch. + pub fn decode_type_mismatch(expected: &str, actual: Value) -> CodecError { + CodecError::expected(expected, &actual) + } + + /// Builds a codec error for an unknown generated enum or union variant. + pub fn decode_unknown_variant(type_name: &str, variant: String) -> CodecError { + CodecError::new(format!( + "unknown variant `{variant}` for WIP type `{type_name}`" + )) + .at(variant) + } + + /// Decoder for a protocol record or operation's named argument collection. + pub struct RecordDecoder { + fields: Vec, + seen: HashSet, + } + + impl RecordDecoder { + /// Starts decoding record fields or named operation arguments and + /// rejects duplicate names. + pub fn new(fields: Vec) -> Result { + let mut seen = HashSet::new(); + for field in &fields { + if !seen.insert(field.name.clone()) { + return Err(CodecError::new(format!("duplicate field `{}`", field.name)) + .at(field.name.clone())); + } + } + Ok(Self { fields, seen }) + } + + /// Decodes a required field. + pub fn required(&mut self, name: &str) -> Result { + let value = self.take(name).ok_or_else(|| { + CodecError::new(format!("missing required field `{name}`")).at(name) + })?; + T::decode(value).map_err(|error| error.at(name)) + } + + /// Decodes an optional field, where absence maps to `None`. + pub fn optional(&mut self, name: &str) -> Result, CodecError> { + self.take(name) + .map(T::decode) + .transpose() + .map_err(|error| error.at(name)) + } + + /// Rejects any fields that were not consumed by generated code. + pub fn finish(self) -> Result<(), CodecError> { + if let Some(field) = self.fields.first() { + Err(CodecError::new(format!("unknown field `{}`", field.name)) + .at(field.name.clone())) + } else { + Ok(()) + } + } + + fn take(&mut self, name: &str) -> Option { + let index = self.fields.iter().position(|field| field.name == name)?; + Some(self.fields.remove(index).value) + } + + /// Returns the set of names supplied to this decoder. + #[allow(dead_code)] + pub fn supplied_names(&self) -> &HashSet { + &self.seen + } + } +} diff --git a/crates/wip-interface/tests/codecs.rs b/crates/wip-interface/tests/codecs.rs new file mode 100644 index 0000000..a75c1cb --- /dev/null +++ b/crates/wip-interface/tests/codecs.rs @@ -0,0 +1,157 @@ +use wip_interface::wip_protocol::{ + EnumDeclaration, JsonMember, JsonValue, NamedValue, RecordDeclaration, TypeDeclaration, + TypeExpr, UnionDeclaration, Value, +}; +use wip_interface::{Bytes, Entry, WipType}; + +/// A request record. +#[derive(Debug, Clone, PartialEq, WipType)] +struct Request { + /// Required title. + title: String, + /// Optional priority. + priority: Option, + /// Related entries. + related: Vec>, + /// Opaque bytes. + body: Bytes, + /// Arbitrary metadata. + metadata: JsonValue, +} + +/// Available colors. +#[derive(Debug, Clone, PartialEq, WipType)] +enum Color { + /// Warm red. + Red, + /// Cool blue. + Blue, +} + +/// A receipt record. +#[derive(Debug, Clone, PartialEq, WipType)] +struct Receipt { + /// Receipt number. + number: u32, +} + +/// Application-level publication outcome. +#[derive(Debug, Clone, PartialEq, WipType)] +enum PublishOutcome { + /// Application success. + Published(Receipt), + /// Domain rejection, not a host failure. + Rejected(String), +} + +#[test] +fn supported_values_round_trip() { + let request = Request { + title: "hello".into(), + priority: None, + related: vec![Entry { + key: "/articles/1".into(), + value: true, + }], + body: Bytes(vec![1, 2, 3]), + metadata: JsonValue::Object(vec![JsonMember { + name: "source".into(), + value: JsonValue::String("test".into()), + }]), + }; + + let encoded = request.clone().encode().unwrap(); + let Value::Record(fields) = &encoded else { + panic!("record derive must encode a protocol record") + }; + assert!(fields.iter().all(|field| field.name != "priority")); + assert_eq!(Request::decode(encoded).unwrap(), request); + + for color in [Color::Red, Color::Blue] { + let encoded = color.clone().encode().unwrap(); + let decoded = Color::decode(encoded).unwrap(); + assert_eq!(decoded, color); + } + + for outcome in [ + PublishOutcome::Published(Receipt { number: 7 }), + PublishOutcome::Rejected("policy".into()), + ] { + let encoded = outcome.clone().encode().unwrap(); + let decoded = PublishOutcome::decode(encoded).unwrap(); + assert_eq!(decoded, outcome); + } +} + +#[test] +fn derived_declarations_include_docs_optionality_and_dependencies() { + let mut declarations = Vec::new(); + Request::collect_declarations(&mut declarations); + Color::collect_declarations(&mut declarations); + PublishOutcome::collect_declarations(&mut declarations); + + let TypeDeclaration::Record(RecordDeclaration { + documentation, + fields, + .. + }) = &declarations[0] + else { + panic!("Request must be a record declaration") + }; + assert_eq!(documentation.as_ref().unwrap().summary, "A request record."); + assert_eq!( + fields[0].documentation.as_ref().unwrap().summary, + "Required title." + ); + assert!(!fields[0].optional); + assert!(fields[1].optional); + assert_eq!(fields[1].ty, TypeExpr::Integer); + assert_eq!( + fields[2].ty, + TypeExpr::List(Box::new(TypeExpr::Entry { + key: Box::new(TypeExpr::String), + value: Box::new(TypeExpr::Boolean), + })) + ); + + let color = declarations + .iter() + .find(|declaration| declaration.name() == "Color") + .unwrap(); + let TypeDeclaration::Enum(EnumDeclaration { variants, .. }) = color else { + panic!("Color must be an enum declaration") + }; + assert_eq!( + variants[0].documentation.as_ref().unwrap().summary, + "Warm red." + ); + + let outcome = declarations + .iter() + .find(|declaration| declaration.name() == "PublishOutcome") + .unwrap(); + let TypeDeclaration::Union(UnionDeclaration { variants, .. }) = outcome else { + panic!("PublishOutcome must be a union declaration") + }; + assert_eq!(variants[0].ty, TypeExpr::Named("Receipt".into())); + assert!( + declarations + .iter() + .any(|declaration| declaration.name() == "Receipt") + ); +} + +#[test] +fn codecs_reject_invalid_boundaries() { + assert!(i64::MAX.encode().is_err()); + assert!(f64::NAN.encode().is_err()); + assert!(Request::decode(Value::Record(vec![])).is_err()); + assert!( + Request::decode(Value::Record(vec![NamedValue { + name: "unknown".into(), + value: Value::String("value".into()), + }])) + .is_err() + ); + assert!(Color::decode(Value::Enum("Green".into())).is_err()); +} diff --git a/crates/wip-interface/tests/compile_fail.rs b/crates/wip-interface/tests/compile_fail.rs new file mode 100644 index 0000000..58ad5fb --- /dev/null +++ b/crates/wip-interface/tests/compile_fail.rs @@ -0,0 +1,5 @@ +#[test] +fn rejects_unsupported_interface_and_dto_syntax() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/*.rs"); +} diff --git a/crates/wip-interface/tests/interface.rs b/crates/wip-interface/tests/interface.rs new file mode 100644 index 0000000..0ac94da --- /dev/null +++ b/crates/wip-interface/tests/interface.rs @@ -0,0 +1,218 @@ +use wip_interface::wip_protocol::{NamedValue, TypeDeclaration, TypeExpr, Value}; +use wip_interface::{DispatchError, HostError, OperationResult, WipType, interface}; + +/// Publication request. +#[derive(Debug, Clone, PartialEq, WipType)] +pub struct PublishRequest { + /// Item title. + pub title: String, +} + +/// Application outcome, including domain failure. +#[derive(Debug, Clone, PartialEq, WipType)] +pub enum PublishOutcome { + /// The item was accepted. + Published(bool), + /// The application rejected the item. + Rejected(String), +} + +/// Catalog editing capability. +/// +/// The shape is implementation-independent. +#[interface] +pub trait Catalog { + /// Publishes one item. + /// + /// This operation can report a domain rejection in its application union. + /// + /// # Returns + /// The application publication outcome. + fn publish( + &self, + /// Item to publish. + request: PublishRequest, + /// Optional audit note. + note: Option, + ) -> OperationResult; + + /// Checks connectivity without returning an application value. + fn ping(&self) -> OperationResult<()>; + + /// Demonstrates a host/runtime failure. + /// + /// # Returns + /// A value only when the host succeeds. + fn host_value(&self) -> OperationResult; +} + +struct AcceptingCatalog; + +impl Catalog for AcceptingCatalog { + fn publish( + &self, + request: PublishRequest, + note: Option, + ) -> OperationResult { + assert_eq!(request.title, "example"); + assert_eq!(note.as_deref(), Some("audit")); + Ok(PublishOutcome::Published(true)) + } + + fn ping(&self) -> OperationResult<()> { + Ok(()) + } + + fn host_value(&self) -> OperationResult { + Err(HostError::new("backend unavailable")) + } +} + +struct RejectingCatalog; + +impl Catalog for RejectingCatalog { + fn publish( + &self, + _request: PublishRequest, + _note: Option, + ) -> OperationResult { + Ok(PublishOutcome::Rejected("policy".into())) + } + + fn ping(&self) -> OperationResult<()> { + Ok(()) + } + + fn host_value(&self) -> OperationResult { + Ok("ready".into()) + } +} + +fn named(name: &str, value: Value) -> NamedValue { + NamedValue { + name: name.into(), + value, + } +} + +fn publish_arguments(with_note: bool) -> Vec { + let mut arguments = vec![named( + "request", + PublishRequest { + title: "example".into(), + } + .encode() + .unwrap(), + )]; + if with_note { + arguments.push(named("note", Value::String("audit".into()))); + } + arguments +} + +#[test] +fn descriptor_is_generated_only_from_the_trait_declaration() { + let first = CatalogDefinition::descriptor(); + let second = CatalogDefinition::descriptor(); + assert_eq!(first, second); + first.validate().unwrap(); + + assert_eq!(first.name, "Catalog"); + assert_eq!( + first.documentation.as_ref().unwrap().summary, + "Catalog editing capability." + ); + assert_eq!( + first.documentation.as_ref().unwrap().details.as_deref(), + Some("The shape is implementation-independent.") + ); + assert_eq!(first.operations.len(), 3); + + let publish = &first.operations[0]; + assert_eq!(publish.name, "publish"); + assert_eq!(publish.parameters[0].name, "request"); + assert_eq!( + publish.parameters[0] + .documentation + .as_ref() + .unwrap() + .summary, + "Item to publish." + ); + assert!(!publish.parameters[0].optional); + assert!(publish.parameters[1].optional); + assert_eq!( + publish + .returns + .as_ref() + .unwrap() + .documentation + .as_ref() + .unwrap() + .summary, + "The application publication outcome." + ); + assert_eq!( + publish.returns.as_ref().unwrap().ty, + TypeExpr::Named("PublishOutcome".into()) + ); + assert!(first.operations[1].returns.is_none()); + assert!( + first + .declarations + .iter() + .any(|declaration| matches!(declaration, TypeDeclaration::Record(_))) + ); + assert!( + first + .declarations + .iter() + .any(|declaration| matches!(declaration, TypeDeclaration::Union(_))) + ); + + // Both implementation types satisfy the same generated dispatch boundary; + // neither can affect the descriptor shape. + fn accepts_definition(_: &T) {} + accepts_definition(&AcceptingCatalog); + accepts_definition(&RejectingCatalog); +} + +#[test] +fn generated_argument_decoder_and_result_encoder_round_trip() { + let decoded = CatalogPublishArguments::decode(publish_arguments(false)).unwrap(); + assert_eq!(decoded.request.title, "example"); + assert_eq!(decoded.note, None); + + let encoded = CatalogPublishArguments::encode_result(PublishOutcome::Rejected("domain".into())) + .unwrap() + .unwrap(); + assert_eq!( + PublishOutcome::decode(encoded).unwrap(), + PublishOutcome::Rejected("domain".into()) + ); +} + +#[test] +fn typed_dispatch_validates_decodes_invokes_encodes_and_validates() { + let value = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", publish_arguments(true)) + .unwrap() + .unwrap(); + assert_eq!( + PublishOutcome::decode(value).unwrap(), + PublishOutcome::Published(true) + ); + + assert_eq!( + CatalogDefinition::dispatch(&AcceptingCatalog, "ping", vec![]).unwrap(), + None + ); + + let invalid = CatalogDefinition::dispatch(&AcceptingCatalog, "publish", vec![]).unwrap_err(); + assert!(matches!(invalid, DispatchError::InvalidArguments(_))); + + let host = CatalogDefinition::dispatch(&AcceptingCatalog, "host_value", vec![]).unwrap_err(); + assert!(matches!(host, DispatchError::Host(error) if error.message() == "backend unavailable")); + + let unknown = CatalogDefinition::dispatch(&AcceptingCatalog, "missing", vec![]).unwrap_err(); + assert!(matches!(unknown, DispatchError::UnknownOperation(name) if name == "missing")); +} diff --git a/crates/wip-interface/tests/ui/associated_type.rs b/crates/wip-interface/tests/ui/associated_type.rs new file mode 100644 index 0000000..56cbaf1 --- /dev/null +++ b/crates/wip-interface/tests/ui/associated_type.rs @@ -0,0 +1,10 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait AssociatedType { + type Item; + + fn load(&self) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/associated_type.stderr b/crates/wip-interface/tests/ui/associated_type.stderr new file mode 100644 index 0000000..d094b9b --- /dev/null +++ b/crates/wip-interface/tests/ui/associated_type.stderr @@ -0,0 +1,13 @@ +error: associated types are not supported in WIP interfaces + --> tests/ui/associated_type.rs:5:5 + | +5 | type Item; + | ^^^^^^^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/associated_type.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/bad_receiver.rs b/crates/wip-interface/tests/ui/bad_receiver.rs new file mode 100644 index 0000000..6fd550a --- /dev/null +++ b/crates/wip-interface/tests/ui/bad_receiver.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait BadReceiver { + fn load(&mut self) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/bad_receiver.stderr b/crates/wip-interface/tests/ui/bad_receiver.stderr new file mode 100644 index 0000000..380ed41 --- /dev/null +++ b/crates/wip-interface/tests/ui/bad_receiver.stderr @@ -0,0 +1,13 @@ +error: the only supported receiver is exactly `&self` + --> tests/ui/bad_receiver.rs:5:13 + | +5 | fn load(&mut self) -> OperationResult; + | ^^^^^^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/bad_receiver.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/bad_return.rs b/crates/wip-interface/tests/ui/bad_return.rs new file mode 100644 index 0000000..3ba1ec8 --- /dev/null +++ b/crates/wip-interface/tests/ui/bad_return.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait BadReturn { + fn load(&self) -> String; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/bad_return.stderr b/crates/wip-interface/tests/ui/bad_return.stderr new file mode 100644 index 0000000..302dc84 --- /dev/null +++ b/crates/wip-interface/tests/ui/bad_return.stderr @@ -0,0 +1,13 @@ +error: an operation must explicitly return `OperationResult` + --> tests/ui/bad_return.rs:5:23 + | +5 | fn load(&self) -> String; + | ^^^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/bad_return.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/custom_lifetime.rs b/crates/wip-interface/tests/ui/custom_lifetime.rs new file mode 100644 index 0000000..aa7a5dd --- /dev/null +++ b/crates/wip-interface/tests/ui/custom_lifetime.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait BorrowedValue<'a> { + fn load(&self, value: &'a str) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/custom_lifetime.stderr b/crates/wip-interface/tests/ui/custom_lifetime.stderr new file mode 100644 index 0000000..b8458b6 --- /dev/null +++ b/crates/wip-interface/tests/ui/custom_lifetime.stderr @@ -0,0 +1,13 @@ +error: generic WIP interface traits are not supported + --> tests/ui/custom_lifetime.rs:4:24 + | +4 | pub trait BorrowedValue<'a> { + | ^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/custom_lifetime.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/duplicate_operation.rs b/crates/wip-interface/tests/ui/duplicate_operation.rs new file mode 100644 index 0000000..c9f728e --- /dev/null +++ b/crates/wip-interface/tests/ui/duplicate_operation.rs @@ -0,0 +1,9 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait DuplicateOperation { + fn load(&self) -> OperationResult; + fn load(&self, other: String) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/duplicate_operation.stderr b/crates/wip-interface/tests/ui/duplicate_operation.stderr new file mode 100644 index 0000000..f9c99ec --- /dev/null +++ b/crates/wip-interface/tests/ui/duplicate_operation.stderr @@ -0,0 +1,13 @@ +error: duplicate operation name `load` + --> tests/ui/duplicate_operation.rs:6:8 + | +6 | fn load(&self, other: String) -> OperationResult; + | ^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/duplicate_operation.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/generic_method.rs b/crates/wip-interface/tests/ui/generic_method.rs new file mode 100644 index 0000000..6e234ea --- /dev/null +++ b/crates/wip-interface/tests/ui/generic_method.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait GenericOperation { + fn load(&self, value: T) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/generic_method.stderr b/crates/wip-interface/tests/ui/generic_method.stderr new file mode 100644 index 0000000..91af3ba --- /dev/null +++ b/crates/wip-interface/tests/ui/generic_method.stderr @@ -0,0 +1,13 @@ +error: generic operations are not supported + --> tests/ui/generic_method.rs:5:12 + | +5 | fn load(&self, value: T) -> OperationResult; + | ^^^ + +warning: unused import: `OperationResult` + --> tests/ui/generic_method.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/inherited_trait.rs b/crates/wip-interface/tests/ui/inherited_trait.rs new file mode 100644 index 0000000..bed13b2 --- /dev/null +++ b/crates/wip-interface/tests/ui/inherited_trait.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait Inherited: Send { + fn load(&self) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/inherited_trait.stderr b/crates/wip-interface/tests/ui/inherited_trait.stderr new file mode 100644 index 0000000..fa8fa93 --- /dev/null +++ b/crates/wip-interface/tests/ui/inherited_trait.stderr @@ -0,0 +1,13 @@ +error: WIP interface trait inheritance is not supported + --> tests/ui/inherited_trait.rs:4:22 + | +4 | pub trait Inherited: Send { + | ^^^^ + +warning: unused import: `OperationResult` + --> tests/ui/inherited_trait.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/mixed_enum.rs b/crates/wip-interface/tests/ui/mixed_enum.rs new file mode 100644 index 0000000..6a1021a --- /dev/null +++ b/crates/wip-interface/tests/ui/mixed_enum.rs @@ -0,0 +1,9 @@ +use wip_interface::WipType; + +#[derive(WipType)] +enum MixedEnum { + Empty, + Payload(String), +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/mixed_enum.stderr b/crates/wip-interface/tests/ui/mixed_enum.stderr new file mode 100644 index 0000000..32ec315 --- /dev/null +++ b/crates/wip-interface/tests/ui/mixed_enum.stderr @@ -0,0 +1,5 @@ +error: a `WipType` enum must contain either only unit variants or only single-field tuple variants; mixed and named-field variants are unsupported + --> tests/ui/mixed_enum.rs:4:1 + | +4 | enum MixedEnum { + | ^^^^ diff --git a/crates/wip-interface/tests/ui/private_trait.rs b/crates/wip-interface/tests/ui/private_trait.rs new file mode 100644 index 0000000..65e2c13 --- /dev/null +++ b/crates/wip-interface/tests/ui/private_trait.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +trait PrivateInterface { + fn load(&self) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/private_trait.stderr b/crates/wip-interface/tests/ui/private_trait.stderr new file mode 100644 index 0000000..f7d3693 --- /dev/null +++ b/crates/wip-interface/tests/ui/private_trait.stderr @@ -0,0 +1,15 @@ +error: a WIP interface trait must be public + --> tests/ui/private_trait.rs:3:1 + | +3 | #[interface] + | ^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `interface` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused import: `OperationResult` + --> tests/ui/private_trait.rs:1:21 + | +1 | use wip_interface::{OperationResult, interface}; + | ^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/crates/wip-interface/tests/ui/tuple_dto.rs b/crates/wip-interface/tests/ui/tuple_dto.rs new file mode 100644 index 0000000..2d36a5a --- /dev/null +++ b/crates/wip-interface/tests/ui/tuple_dto.rs @@ -0,0 +1,6 @@ +use wip_interface::WipType; + +#[derive(WipType)] +struct TupleDto(String, String); + +fn main() {} diff --git a/crates/wip-interface/tests/ui/tuple_dto.stderr b/crates/wip-interface/tests/ui/tuple_dto.stderr new file mode 100644 index 0000000..349591b --- /dev/null +++ b/crates/wip-interface/tests/ui/tuple_dto.stderr @@ -0,0 +1,5 @@ +error: `WipType` supports only structs with named fields + --> tests/ui/tuple_dto.rs:4:16 + | +4 | struct TupleDto(String, String); + | ^^^^^^^^^^^^^^^^ diff --git a/crates/wip-interface/tests/ui/unsupported_type.rs b/crates/wip-interface/tests/ui/unsupported_type.rs new file mode 100644 index 0000000..3ea6406 --- /dev/null +++ b/crates/wip-interface/tests/ui/unsupported_type.rs @@ -0,0 +1,8 @@ +use wip_interface::{OperationResult, interface}; + +#[interface] +pub trait UnsupportedType { + fn load(&self, pair: (String, String)) -> OperationResult; +} + +fn main() {} diff --git a/crates/wip-interface/tests/ui/unsupported_type.stderr b/crates/wip-interface/tests/ui/unsupported_type.stderr new file mode 100644 index 0000000..22de413 --- /dev/null +++ b/crates/wip-interface/tests/ui/unsupported_type.stderr @@ -0,0 +1,41 @@ +error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied + --> tests/ui/unsupported_type.rs:5:26 + | +5 | fn load(&self, pair: (String, String)) -> OperationResult; + | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` + | + = help: the following other types implement trait `WipType`: + JsonValue + Vec + bool + f32 + f64 + i128 + i16 + i32 + and $N others + +error[E0277]: the trait bound `(std::string::String, std::string::String): WipType` is not satisfied + --> tests/ui/unsupported_type.rs:5:26 + | +3 | #[interface] + | ------------ required by a bound introduced by this call +4 | pub trait UnsupportedType { +5 | fn load(&self, pair: (String, String)) -> OperationResult; + | ^^^^^^^^^^^^^^^^ the trait `WipType` is not implemented for `(std::string::String, std::string::String)` + | + = help: the following other types implement trait `WipType`: + JsonValue + Vec + bool + f32 + f64 + i128 + i16 + i32 + and $N others +note: required by a bound in `wip_interface::__private::RecordDecoder::required` + --> src/lib.rs + | + | pub fn required(&mut self, name: &str) -> Result { + | ^^^^^^^ required by this bound in `RecordDecoder::required`