Skip to content

Commit c89aa89

Browse files
committed
Add GenServer v2 with Message trait for typed actors
Introduce a new GenServer v2 implementation with: - `&mut self` style handlers (struct IS the process state) - Typed message handlers via `Call<M>`, `Cast<M>`, `Info<M>` traits - Clean result types: `Init`, `Reply`, `Status` - `Message` trait with `encode_local`/`encode_remote` for typed serialization - `#[derive(Message)]` macro for automatic message encoding/decoding The Message trait provides self-describing messages with tag prefixes for type discrimination when receiving raw bytes. Local encoding uses postcard with a tag prefix, while remote encoding is prepared for ETF (BEAM interop). Also includes: - ETF helper module for Erlang Term Format integration - Clippy warning fixes across the codebase
1 parent 95b5394 commit c89aa89

16 files changed

Lines changed: 2091 additions & 15 deletions

File tree

crates/ambitious-macros/src/lib.rs

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ extern crate proc_macro;
4848

4949
use proc_macro::TokenStream;
5050
use quote::quote;
51-
use syn::{DeriveInput, ItemFn, parse_macro_input};
51+
use syn::{Data, DeriveInput, Fields, ItemFn, parse_macro_input};
5252

5353
/// Derive macro for GenServer implementation helpers.
5454
///
@@ -229,3 +229,172 @@ pub fn test(_attr: TokenStream, item: TokenStream) -> TokenStream {
229229

230230
TokenStream::from(expanded)
231231
}
232+
233+
/// Derive macro for implementing the Message trait.
234+
///
235+
/// This generates implementations for `encode_local`, `decode_local`,
236+
/// `encode_remote`, and `decode_remote` with automatic type tagging.
237+
///
238+
/// # Supported Types
239+
///
240+
/// - Unit structs: `struct Ping;`
241+
/// - Newtype structs: `struct Add(i64);`
242+
/// - Structs with named fields: `struct Login { user: String, pass: String }`
243+
///
244+
/// # Attributes
245+
///
246+
/// - `#[message(tag = "custom")]` - Override the default tag (type name)
247+
///
248+
/// # Example
249+
///
250+
/// ```ignore
251+
/// use ambitious::Message;
252+
///
253+
/// #[derive(Message)]
254+
/// struct Get;
255+
///
256+
/// #[derive(Message)]
257+
/// #[message(tag = "increment")]
258+
/// struct Inc;
259+
///
260+
/// #[derive(Message)]
261+
/// struct Add(i64);
262+
///
263+
/// #[derive(Message)]
264+
/// struct SetUser {
265+
/// name: String,
266+
/// age: u32,
267+
/// }
268+
/// ```
269+
#[proc_macro_derive(Message, attributes(message))]
270+
pub fn derive_message(input: TokenStream) -> TokenStream {
271+
let input = parse_macro_input!(input as DeriveInput);
272+
let name = &input.ident;
273+
274+
// Check for custom tag attribute
275+
let tag = get_message_tag(&input).unwrap_or_else(|| name.to_string());
276+
277+
// Generate different code based on struct variant
278+
let (encode_body, decode_body) = match &input.data {
279+
Data::Struct(data) => match &data.fields {
280+
Fields::Unit => {
281+
// Unit struct: struct Ping;
282+
(
283+
quote! {
284+
::ambitious::message::encode_with_tag(Self::tag(), &[])
285+
},
286+
quote! {
287+
let _ = bytes;
288+
Ok(Self)
289+
},
290+
)
291+
}
292+
Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
293+
// Newtype struct: struct Add(i64);
294+
(
295+
quote! {
296+
let payload = ::ambitious::message::encode_payload(&self.0);
297+
::ambitious::message::encode_with_tag(Self::tag(), &payload)
298+
},
299+
quote! {
300+
let inner = ::ambitious::message::decode_payload(bytes)?;
301+
Ok(Self(inner))
302+
},
303+
)
304+
}
305+
Fields::Unnamed(_) => {
306+
// Multi-field tuple struct not supported yet
307+
return syn::Error::new_spanned(
308+
input,
309+
"Message derive only supports unit structs, newtype structs (single field), and named structs",
310+
)
311+
.to_compile_error()
312+
.into();
313+
}
314+
Fields::Named(fields) => {
315+
// Named struct: struct Login { user: String }
316+
let field_names: Vec<_> = fields.named.iter().map(|f| &f.ident).collect();
317+
318+
(
319+
quote! {
320+
let payload = ::ambitious::message::encode_payload(&(#(&self.#field_names),*));
321+
::ambitious::message::encode_with_tag(Self::tag(), &payload)
322+
},
323+
quote! {
324+
let (#(#field_names),*) = ::ambitious::message::decode_payload(bytes)?;
325+
Ok(Self { #(#field_names),* })
326+
},
327+
)
328+
}
329+
},
330+
Data::Enum(_) => {
331+
return syn::Error::new_spanned(
332+
input,
333+
"Message derive does not support enums yet. Use separate structs for each message type.",
334+
)
335+
.to_compile_error()
336+
.into();
337+
}
338+
Data::Union(_) => {
339+
return syn::Error::new_spanned(input, "Message derive does not support unions")
340+
.to_compile_error()
341+
.into();
342+
}
343+
};
344+
345+
let expanded = quote! {
346+
impl ::ambitious::message::Message for #name {
347+
fn tag() -> &'static str {
348+
#tag
349+
}
350+
351+
fn encode_local(&self) -> Vec<u8> {
352+
#encode_body
353+
}
354+
355+
fn decode_local(bytes: &[u8]) -> Result<Self, ::ambitious::core::DecodeError> {
356+
#decode_body
357+
}
358+
359+
fn encode_remote(&self) -> Vec<u8> {
360+
// For now, use same encoding as local
361+
// TODO: Implement ETF encoding
362+
self.encode_local()
363+
}
364+
365+
fn decode_remote(bytes: &[u8]) -> Result<Self, ::ambitious::core::DecodeError> {
366+
// For now, use same decoding as local
367+
// TODO: Implement ETF decoding
368+
Self::decode_local(bytes)
369+
}
370+
}
371+
};
372+
373+
TokenStream::from(expanded)
374+
}
375+
376+
/// Extract custom tag from #[message(tag = "...")] attribute
377+
fn get_message_tag(input: &DeriveInput) -> Option<String> {
378+
for attr in &input.attrs {
379+
if attr.path().is_ident("message")
380+
&& let Ok(meta) = attr.meta.require_list()
381+
{
382+
let tokens = meta.tokens.to_string();
383+
// Simple parsing: look for tag = "value"
384+
if let Some(start) = tokens.find("tag") {
385+
let rest = &tokens[start..];
386+
if let Some(eq_pos) = rest.find('=') {
387+
let after_eq = &rest[eq_pos + 1..].trim();
388+
// Extract string value between quotes
389+
if let Some(quote_start) = after_eq.find('"') {
390+
let after_quote = &after_eq[quote_start + 1..];
391+
if let Some(quote_end) = after_quote.find('"') {
392+
return Some(after_quote[..quote_end].to_string());
393+
}
394+
}
395+
}
396+
}
397+
}
398+
}
399+
None
400+
}

crates/ambitious-macros/tests/derive_test.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Integration tests for ambitious-macros.
22
3-
use ambitious_macros::{GenServerImpl, ambitious_process};
3+
use ambitious::message::{Message, decode_tag};
4+
use ambitious_macros::{GenServerImpl, Message as DeriveMessage, ambitious_process};
45

56
#[derive(GenServerImpl)]
67
struct TestServer {
@@ -24,3 +25,76 @@ fn test_ambitious_process_attribute() {
2425
// The attribute should not change the function signature
2526
// This test passes if it compiles
2627
}
28+
29+
// =========================================================================
30+
// Message derive macro tests
31+
// =========================================================================
32+
33+
// Unit struct with derive
34+
#[derive(DeriveMessage)]
35+
struct Ping;
36+
37+
#[test]
38+
fn test_derive_unit_struct() {
39+
let msg = Ping;
40+
let bytes = msg.encode_local();
41+
42+
let (tag, payload) = decode_tag(&bytes).unwrap();
43+
assert_eq!(tag, "Ping");
44+
assert!(payload.is_empty());
45+
46+
let _decoded = Ping::decode_local(payload).unwrap();
47+
}
48+
49+
// Newtype struct with derive
50+
#[derive(DeriveMessage)]
51+
struct Add(i64);
52+
53+
#[test]
54+
fn test_derive_newtype_struct() {
55+
let msg = Add(42);
56+
let bytes = msg.encode_local();
57+
58+
let (tag, payload) = decode_tag(&bytes).unwrap();
59+
assert_eq!(tag, "Add");
60+
61+
let decoded = Add::decode_local(payload).unwrap();
62+
assert_eq!(decoded.0, 42);
63+
}
64+
65+
// Named struct with derive
66+
#[derive(DeriveMessage, Debug, PartialEq)]
67+
struct Login {
68+
username: String,
69+
password: String,
70+
}
71+
72+
#[test]
73+
fn test_derive_named_struct() {
74+
let msg = Login {
75+
username: "alice".to_string(),
76+
password: "secret".to_string(),
77+
};
78+
let bytes = msg.encode_local();
79+
80+
let (tag, payload) = decode_tag(&bytes).unwrap();
81+
assert_eq!(tag, "Login");
82+
83+
let decoded = Login::decode_local(payload).unwrap();
84+
assert_eq!(decoded.username, "alice");
85+
assert_eq!(decoded.password, "secret");
86+
}
87+
88+
// Custom tag
89+
#[derive(DeriveMessage)]
90+
#[message(tag = "increment")]
91+
struct Inc;
92+
93+
#[test]
94+
fn test_derive_custom_tag() {
95+
let msg = Inc;
96+
let bytes = msg.encode_local();
97+
98+
let (tag, _) = decode_tag(&bytes).unwrap();
99+
assert_eq!(tag, "increment");
100+
}

crates/ambitious/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ hostname = "0.4"
3636
tokio-tungstenite = { version = "0.28", optional = true }
3737
serde_json = { version = "1", optional = true }
3838
futures = { version = "0.3", optional = true }
39+
erltf = "0.14.0"
40+
erltf_serde = "0.14.0"
3941

4042
[features]
4143
default = []

0 commit comments

Comments
 (0)