fix(interface): harden generated public API

This commit is contained in:
2026-09-29 00:04:04 +09:00
parent d0683f0b6d
commit 5992322371
10 changed files with 108 additions and 17 deletions
+35 -9
View File
@@ -1,4 +1,10 @@
//! Procedural macros backing the public `wip-interface` authoring facade. //! Procedural macros backing the public `wip-interface` authoring facade.
//!
//! This is an implementation crate. Applications should depend on
//! `wip-interface`, which re-exports both macros alongside the traits and types
//! used by their generated code.
#![deny(missing_docs)]
use std::collections::HashSet; use std::collections::HashSet;
@@ -470,13 +476,18 @@ fn parse_operation_result(output: &ReturnType) -> syn::Result<Type> {
_ => None, _ => None,
}); });
let result = types.next().cloned(); let result = types.next().cloned();
if arguments.args.len() != 1 || result.is_none() || types.next().is_some() { if arguments.args.len() != 1 || types.next().is_some() {
return Err(Error::new_spanned( return Err(Error::new_spanned(
arguments, arguments,
"`OperationResult` must have exactly one application result type", "`OperationResult` must have exactly one application result type",
)); ));
} }
let result = result.expect("checked above"); let result = result.ok_or_else(|| {
Error::new_spanned(
arguments,
"`OperationResult` must have exactly one application result type",
)
})?;
if direct_option(&result)?.is_some() || contains_option(&result) { if direct_option(&result)?.is_some() || contains_option(&result) {
return Err(Error::new_spanned( return Err(Error::new_spanned(
result, result,
@@ -490,8 +501,13 @@ fn argument_item(operation: &Operation) -> TokenStream2 {
let arguments_ident = &operation.arguments_ident; let arguments_ident = &operation.arguments_ident;
let fields = operation.parameters.iter().map(|parameter| { let fields = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident; let ident = &parameter.ident;
let name = &parameter.name;
let ty = parameter_rust_type(parameter); let ty = parameter_rust_type(parameter);
quote!(pub #ident: #ty) let documentation = format!("Decoded `{name}` operation argument.");
quote!(
#[doc = #documentation]
pub #ident: #ty
)
}); });
let decoders = operation.parameters.iter().map(|parameter| { let decoders = operation.parameters.iter().map(|parameter| {
let ident = &parameter.ident; let ident = &parameter.ident;
@@ -698,7 +714,12 @@ fn derive_record(
let mut parsed_fields = Vec::new(); let mut parsed_fields = Vec::new();
let mut names = HashSet::new(); let mut names = HashSet::new();
for field in fields { for field in fields {
let field_ident = field.ident.clone().expect("named fields have identifiers"); let Some(field_ident) = field.ident.clone() else {
return Err(Error::new_spanned(
field,
"`WipType` record fields must be named",
));
};
let name = source_name(&field_ident); let name = source_name(&field_ident);
if !names.insert(name.clone()) { if !names.insert(name.clone()) {
return Err(Error::new( return Err(Error::new(
@@ -984,12 +1005,17 @@ fn derive_union_enum(
let mut variants = Vec::new(); let mut variants = Vec::new();
for variant in &data.variants { for variant in &data.variants {
let Fields::Unnamed(fields) = &variant.fields else { let Fields::Unnamed(fields) = &variant.fields else {
unreachable!("enum shape was checked before union generation"); return Err(Error::new_spanned(
variant,
"a `WipType` union variant must contain exactly one unnamed field",
));
};
let Some(field) = fields.unnamed.first() else {
return Err(Error::new_spanned(
fields,
"a `WipType` union variant must contain exactly one unnamed field",
));
}; };
let field = fields
.unnamed
.first()
.expect("union variants have one field");
if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) { if direct_option(&field.ty)?.is_some() || contains_option(&field.ty) {
return Err(Error::new_spanned( return Err(Error::new_spanned(
&field.ty, &field.ty,
+16 -8
View File
@@ -48,6 +48,21 @@
//! an application return enum/union and is returned as `Ok(value)`. //! an application return enum/union and is returned as `Ok(value)`.
//! [`HostError`] is reserved for host/runtime failure, so the required //! [`HostError`] is reserved for host/runtime failure, so the required
//! [`OperationResult`] convention keeps those two failure classes distinct. //! [`OperationResult`] convention keeps those two failure classes distinct.
//!
//! # Supported interface syntax
//!
//! An interface is a public, non-generic trait whose operations use exactly
//! `&self`, simple named owned parameters, and an explicit
//! `OperationResult<T>`. `WipType` derives support named-field structs,
//! all-unit enums, and all-single-payload enums.
//!
//! The macros reject trait inheritance and associated items; const, async,
//! unsafe, extern, variadic, generic, default-bodied, or non-`&self` methods;
//! borrowed values and custom lifetimes; tuple, unit, or generic structs;
//! mixed or named-field enum variants; explicit discriminants; Rust unions;
//! and `Option` anywhere except a direct DTO field or operation parameter.
//! Unsupported inputs produce source-located compile errors rather than
//! generating a partial interface.
#![deny(missing_docs)] #![deny(missing_docs)]
@@ -619,7 +634,6 @@ pub mod __private {
/// Decoder for a protocol record or operation's named argument collection. /// Decoder for a protocol record or operation's named argument collection.
pub struct RecordDecoder { pub struct RecordDecoder {
fields: Vec<NamedValue>, fields: Vec<NamedValue>,
seen: HashSet<String>,
} }
impl RecordDecoder { impl RecordDecoder {
@@ -633,7 +647,7 @@ pub mod __private {
.at(field.name.clone())); .at(field.name.clone()));
} }
} }
Ok(Self { fields, seen }) Ok(Self { fields })
} }
/// Decodes a required field. /// Decodes a required field.
@@ -666,11 +680,5 @@ pub mod __private {
let index = self.fields.iter().position(|field| field.name == name)?; let index = self.fields.iter().position(|field| field.name == name)?;
Some(self.fields.remove(index).value) Some(self.fields.remove(index).value)
} }
/// Returns the set of names supplied to this decoder.
#[allow(dead_code)]
pub fn supplied_names(&self) -> &HashSet<String> {
&self.seen
}
} }
} }
@@ -0,0 +1,8 @@
use wip_interface::interface;
#[interface]
pub trait AsyncOperation {
async fn status(&self) -> wip_interface::OperationResult<String>;
}
fn main() {}
@@ -0,0 +1,5 @@
error: async operations are not supported
--> tests/ui/async_operation.rs:5:5
|
5 | async fn status(&self) -> wip_interface::OperationResult<String>;
| ^^^^^
@@ -0,0 +1,10 @@
use wip_interface::interface;
#[interface]
pub trait DefaultMethod {
fn status(&self) -> wip_interface::OperationResult<String> {
Ok(String::new())
}
}
fn main() {}
@@ -0,0 +1,8 @@
error: WIP operation methods must not have a body
--> tests/ui/default_method.rs:5:64
|
5 | fn status(&self) -> wip_interface::OperationResult<String> {
| ________________________________________________________________^
6 | | Ok(String::new())
7 | | }
| |_____^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct GenericDto<T> {
value: T,
}
fn main() {}
@@ -0,0 +1,5 @@
error: generic DTOs are not supported by `WipType`
--> tests/ui/generic_dto.rs:4:18
|
4 | struct GenericDto<T> {
| ^^^
@@ -0,0 +1,8 @@
use wip_interface::WipType;
#[derive(WipType)]
struct NestedOption {
values: Vec<Option<String>>,
}
fn main() {}
@@ -0,0 +1,5 @@
error: `Option<T>` must appear directly as the DTO field type
--> tests/ui/nested_option.rs:5:13
|
5 | values: Vec<Option<String>>,
| ^^^^^^^^^^^^^^^^^^^