sausage-plugin part 1: run, #[rpc], #[schema], rpc.discover, calls, locks, Ctx, hooks

Checkpoint. Known issues, fixed in part 2: short writes on stdout,
byte-wise stdin reads, Result suffix on all result schemas, lost enum
variant docs, serde errors as invalid params, missing item docs.
This commit is contained in:
2026-10-23 00:05:37 +01:00
parent 4e41061ed4
commit 01712e745a
30 changed files with 1722 additions and 93 deletions
+3
View File
@@ -8,3 +8,6 @@ description = "Macros of the Sausage plugin SDK. Use them through `sausage-plugi
proc-macro = true
[dependencies]
syn = { version = "2", features = ["full"] }
quote = "1"
proc-macro2 = "1"
+251 -1
View File
@@ -1 +1,251 @@
//! Macros of the Sausage plugin SDK. Use them through `sausage-plugin`.
//! Attribute macros for documented schemas and plugin dispatch.
use proc_macro::TokenStream;
use quote::quote;
use syn::{
Attribute, Fields, FnArg, ImplItem, Item, ItemImpl, ReturnType, Type, parse_macro_input,
spanned::Spanned,
};
fn docs(attrs: &[Attribute]) -> String {
attrs
.iter()
.filter_map(|a| {
if a.path().is_ident("doc")
&& let syn::Meta::NameValue(n) = &a.meta
&& let syn::Expr::Lit(e) = &n.value
&& let syn::Lit::Str(s) = &e.lit
{
return Some(s.value().trim().to_string());
}
None
})
.collect::<Vec<_>>()
.join("\n")
}
fn documented(attrs: &[Attribute], span: proc_macro2::Span) -> syn::Result<()> {
if docs(attrs).is_empty() {
Err(syn::Error::new(span, "a doc comment is required"))
} else {
Ok(())
}
}
fn serde_subset(attrs: &[Attribute]) -> syn::Result<()> {
for a in attrs {
if a.path().is_ident("serde") {
a.parse_nested_meta(|m| {
if !["rename", "rename_all", "default", "skip_serializing_if"]
.iter()
.any(|s| m.path.is_ident(s))
{
return Err(m.error("serde attribute is outside the schema subset"));
}
if m.input.peek(syn::Token![=]) {
let _: syn::Expr = m.value()?.parse()?;
}
Ok(())
})?;
}
}
Ok(())
}
#[proc_macro_attribute]
pub fn schema(_: TokenStream, input: TokenStream) -> TokenStream {
let item = parse_macro_input!(input as Item);
let result = (|| -> syn::Result<proc_macro2::TokenStream> {
let (name, attrs, fields, is_params) = match &item {
Item::Struct(s) => {
if !matches!(s.fields, Fields::Named(_)) {
return Err(syn::Error::new(
s.span(),
"schema structs require named fields",
));
}
(
&s.ident,
&s.attrs,
s.fields.iter().collect::<Vec<_>>(),
true,
)
}
Item::Enum(e) => {
for v in &e.variants {
if !matches!(v.fields, Fields::Unit) {
return Err(syn::Error::new(
v.span(),
"schema enums require unit variants",
));
}
serde_subset(&v.attrs)?;
}
(&e.ident, &e.attrs, vec![], false)
}
_ => {
return Err(syn::Error::new(
item.span(),
"schema requires a struct or unit enum",
));
}
};
documented(attrs, name.span())?;
serde_subset(attrs)?;
let mut types = vec![];
for f in fields {
documented(&f.attrs, f.span())?;
serde_subset(&f.attrs)?;
types.push(&f.ty);
}
let params = if is_params {
quote!(impl ::sausage_plugin::Params for #name {})
} else {
quote!()
};
Ok(quote! {
#[derive(::sausage_plugin::serde::Serialize,::sausage_plugin::serde::Deserialize,::sausage_plugin::schemars::JsonSchema)]
#[serde(crate="::sausage_plugin::serde")]
#[schemars(crate="::sausage_plugin::schemars")]
#item
impl ::sausage_plugin::SchemaType for #name {}
#params
const _: () = { fn check() { fn supported<T: ::sausage_plugin::SchemaType>() {} #(supported::<#types>();)* } };
})
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#[proc_macro_attribute]
pub fn rpc(args: TokenStream, input: TokenStream) -> TokenStream {
let mut item = parse_macro_input!(input as ItemImpl);
let args = proc_macro2::TokenStream::from(args);
let result = (|| -> syn::Result<proc_macro2::TokenStream> {
let mut grace = None;
let parser = syn::meta::parser(|m| {
if m.path.is_ident("grace_seconds") {
grace = Some(m.value()?.parse::<syn::LitInt>()?);
Ok(())
} else if m.path.is_ident("endpoints") {
let content;
syn::parenthesized!(content in m.input);
let _ = content.parse::<proc_macro2::TokenStream>()?;
Ok(())
} else {
Err(m.error("unknown RPC option"))
}
});
syn::parse::Parser::parse2(parser, args)?;
let ty = &item.self_ty;
let mut arms = vec![];
let mut entries = vec![];
let mut stop = quote!();
for member in &mut item.items {
let ImplItem::Fn(f) = member else { continue };
let retired = f.attrs.iter().any(|a| {
a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_retired")
});
let stopping = f.attrs.iter().any(|a| {
a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_stop")
});
f.attrs.retain(|a| {
!a.path()
.segments
.last()
.is_some_and(|s| s.ident == "on_retired" || s.ident == "on_stop")
});
documented(&f.attrs, f.span())?;
if f.sig.inputs.is_empty()
|| !matches!(f.sig.inputs.first(),Some(FnArg::Receiver(r)) if r.reference.is_some() && r.mutability.is_none())
{
return Err(syn::Error::new(f.sig.span(), "RPC methods require &self"));
}
let name = &f.sig.ident;
let mut args = vec![];
let mut param = None;
for arg in f.sig.inputs.iter().skip(1) {
let FnArg::Typed(p) = arg else { unreachable!() };
let is_ctx = matches!(p.ty.as_ref(),Type::Reference(r) if matches!(r.elem.as_ref(),Type::Path(t) if t.path.segments.last().is_some_and(|s|s.ident=="Ctx")));
if is_ctx {
if !args.is_empty() {
return Err(syn::Error::new(p.span(), "ctx must follow &self"));
}
args.push(quote!(ctx));
} else {
if param.is_some() {
return Err(syn::Error::new(
p.span(),
"RPC methods take at most one params struct",
));
}
param = Some(p.ty.as_ref());
args.push(quote!(::sausage_plugin::serde_json::from_value(params)?));
}
}
let await_ = f.sig.asyncness.map(|_| quote!(.await));
if stopping {
if !args.is_empty() {
return Err(syn::Error::new(f.span(), "stop hook takes only &self"));
}
stop = quote!(fn stop(&self)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=()> + '_>> {Box::pin(async move {let _=self.#name() #await_;})});
continue;
}
let wire = if retired {
"rpc.retired".to_string()
} else {
name.to_string()
};
let p = param
.map(|t| quote!(#t))
.unwrap_or(quote!(::sausage_plugin::Empty));
let output = match &f.sig.output {
ReturnType::Type(_, t) => match t.as_ref() {
Type::Path(t)
if t.path.segments.last().is_some_and(|s| s.ident == "Result") =>
{
let syn::PathArguments::AngleBracketed(a) =
&t.path.segments.last().unwrap().arguments
else {
return Err(syn::Error::new(t.span(), "expected Result<T>"));
};
let Some(syn::GenericArgument::Type(t)) = a.args.first() else {
return Err(syn::Error::new(a.span(), "expected Result<T>"));
};
quote!(#t)
}
_ => return Err(syn::Error::new(t.span(), "RPC methods return Result<T>")),
},
_ => return Err(syn::Error::new(f.span(), "RPC methods return Result<T>")),
};
let doc = docs(&f.attrs);
arms.push(quote!(#wire => { fn params_type<T: ::sausage_plugin::Params>() {} params_type::<#p>(); let result=self.#name(#(#args),*) #await_ ?; Ok(::sausage_plugin::serde_json::to_value(result)?) }));
if !retired {
entries.push(quote!(::sausage_plugin::method::<#p,#output>(&mut doc,#wire,#doc);));
}
}
let grace =
grace.map(|n| quote!(doc["x-sausage-grace"]=::sausage_plugin::serde_json::json!(#n);));
Ok(quote! {#item impl ::sausage_plugin::Interface for #ty {
fn discover(&self)->::sausage_plugin::serde_json::Value {let mut doc=::sausage_plugin::document(env!("CARGO_PKG_NAME"),env!("CARGO_PKG_VERSION")); #(#entries)* #grace doc}
fn dispatch<'a>(&'a self, method:&'a str, params: ::sausage_plugin::serde_json::Value,ctx:&'a ::sausage_plugin::Ctx)->::std::pin::Pin<Box<dyn ::std::future::Future<Output=::sausage_plugin::Result<::sausage_plugin::serde_json::Value>>+'a>> {Box::pin(async move {if !params.is_object() {return Err(::sausage_plugin::Error::new(-32602,"params must be an object"));} match method {#(#arms,)* _=>Err(::sausage_plugin::Error::new(-32601,"method not found"))}})}
#stop
}})
})();
result.unwrap_or_else(syn::Error::into_compile_error).into()
}
#[proc_macro_attribute]
pub fn on_retired(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
#[proc_macro_attribute]
pub fn on_stop(_: TokenStream, input: TokenStream) -> TokenStream {
input
}
#[proc_macro_attribute]
pub fn test(_: TokenStream, input: TokenStream) -> TokenStream {
let mut f = parse_macro_input!(input as syn::ItemFn);
f.sig.asyncness = None;
let body = f.block;
f.block = Box::new(syn::parse_quote!({::sausage_plugin::test_run(async #body)}));
quote!(#[::core::prelude::v1::test] #f).into()
}