plexus_macros/lib.rs
1//! # plexus-macros
2//!
3//! Procedural macros for **Plexus RPC** activations. Your Rust function
4//! signature IS the schema — the macros extract method names, parameter
5//! types, return types, doc comments, deprecation metadata, child routing,
6//! and typed request extraction from the source.
7//!
8//! See the crate-level [`README`](https://github.com/hypermemetic/plexus-macros/blob/main/README.md)
9//! for a comprehensive example covering every option, a deep-dive on
10//! request forwarding (`PlexusRequest` + `request = ...`), and a
11//! per-macro reference.
12//!
13//! # Quick example
14//!
15//! ```ignore
16//! use futures::Stream;
17//! use async_stream::stream;
18//! use plexus_macros::PlexusRequest;
19//!
20//! #[derive(PlexusRequest)]
21//! pub struct BashRequest {
22//! #[from_cookie("access_token")]
23//! auth_token: String,
24//! }
25//!
26//! pub struct Bash;
27//!
28//! #[plexus_macros::activation(
29//! namespace = "bash",
30//! version = "1.0.0",
31//! description = "Execute bash commands and stream output",
32//! request = BashRequest,
33//! )]
34//! impl Bash {
35//! /// Execute a bash command and stream output.
36//! #[plexus_macros::method(streaming)]
37//! async fn execute(
38//! &self,
39//! command: String,
40//! #[activation_param] auth_token: String,
41//! ) -> impl Stream<Item = String> + Send + 'static {
42//! let _ = (command, auth_token);
43//! stream! { yield "ok".into(); }
44//! }
45//! }
46//! ```
47//!
48//! # Macros at a glance
49//!
50//! | Macro | Purpose |
51//! |---|---|
52//! | `#[plexus_macros::activation]` | Turn an `impl` block into a Plexus RPC activation. |
53//! | `#[plexus_macros::method]` | Mark a method as an RPC endpoint. |
54//! | `#[plexus_macros::child]` | Register a child activation for `ChildRouter` dispatch. |
55//! | `#[plexus_macros::removed_in]` | Companion to `#[deprecated]` carrying the removal version. |
56//! | `#[derive(PlexusRequest)]` | Typed extraction from `RawRequestContext`. |
57//! | `#[derive(HandleEnum)]` | Type-safe handle enums for an activation. |
58//!
59//! Deprecated surfaces kept through 0.5.x (removed in 0.6): `#[hub_methods]`,
60//! `#[hub_method]`, `#[derive(StreamEvent)]`, the `hub` and `children = [...]`
61//! flags on `#[activation]`. See the migration section of the README.
62
63mod codegen;
64mod handle_enum;
65mod parse;
66mod request;
67mod stream_event;
68
69
70use codegen::generate_all;
71use parse::HubMethodsAttrs;
72use proc_macro::TokenStream;
73use proc_macro2::TokenStream as TokenStream2;
74use quote::{format_ident, quote};
75use syn::{
76 parse::{Parse, ParseStream},
77 parse_macro_input, punctuated::Punctuated, Expr, ExprLit, FnArg, ItemFn, ItemImpl, Lit, Meta,
78 MetaNameValue, Pat, ReturnType, Token, Type,
79};
80
81/// Parsed attributes for hub_method (standalone version)
82struct HubMethodAttrs {
83 name: Option<String>,
84 /// Explicit description from `description = "..."`. When `Some`, this wins over
85 /// `///` doc comments. When `None`, doc comments are used as the default.
86 description: Option<String>,
87 /// Base crate path for imports (default: "crate")
88 crate_path: String,
89}
90
91impl Parse for HubMethodAttrs {
92 fn parse(input: ParseStream) -> syn::Result<Self> {
93 let mut name = None;
94 let mut description: Option<String> = None;
95 let mut crate_path = "crate".to_string();
96
97 if !input.is_empty() {
98 let metas = Punctuated::<Meta, Token![,]>::parse_terminated(input)?;
99
100 for meta in metas {
101 if let Meta::NameValue(MetaNameValue { path, value, .. }) = meta {
102 if path.is_ident("name") {
103 if let Expr::Lit(ExprLit {
104 lit: Lit::Str(s), ..
105 }) = value
106 {
107 name = Some(s.value());
108 }
109 } else if path.is_ident("description") {
110 if let Expr::Lit(ExprLit {
111 lit: Lit::Str(s), ..
112 }) = value
113 {
114 description = Some(s.value());
115 }
116 } else if path.is_ident("crate_path") {
117 if let Expr::Lit(ExprLit {
118 lit: Lit::Str(s), ..
119 }) = value
120 {
121 crate_path = s.value();
122 }
123 }
124 }
125 }
126 }
127
128 Ok(HubMethodAttrs { name, description, crate_path })
129 }
130}
131
132/// Attribute macro for individual methods within a `#[plexus::activation]` impl block.
133///
134/// # Example
135///
136/// ```ignore
137/// #[plexus::activation(namespace = "bash")]
138/// impl Bash {
139/// /// Execute a bash command
140/// #[plexus::method]
141/// async fn execute(&self, command: String) -> impl Stream<Item = BashEvent> {
142/// // ...
143/// }
144/// }
145/// ```
146#[proc_macro_attribute]
147pub fn method(attr: TokenStream, item: TokenStream) -> TokenStream {
148 let args = parse_macro_input!(attr as HubMethodAttrs);
149 let input_fn = parse_macro_input!(item as ItemFn);
150
151 match hub_method_impl(args, input_fn) {
152 Ok(tokens) => tokens.into(),
153 Err(e) => e.to_compile_error().into(),
154 }
155}
156
157/// Deprecated: use `plexus::method` instead.
158#[deprecated(since = "0.5.0", note = "Use `plexus::method` instead")]
159#[proc_macro_attribute]
160pub fn hub_method(attr: TokenStream, item: TokenStream) -> TokenStream {
161 method(attr, item)
162}
163
164/// Attribute macro for individual **child** methods within a
165/// `#[plexus_macros::activation]` impl block.
166///
167/// Two method shapes are accepted:
168///
169/// | Shape | Role | Example |
170/// |---|---|---|
171/// | `fn NAME(&self) -> Child` (no args) | Static child — routing name is the method name | `fn mercury(&self) -> Mercury` |
172/// | `fn NAME(&self, name: &str) -> Option<Child>` (sync or async) | Dynamic fallback dispatcher | `fn planet(&self, name: &str) -> Option<Planet>` |
173///
174/// The enclosing `#[plexus_macros::activation]` macro collects all `#[child]`
175/// methods and generates a `ChildRouter` impl whose `get_child(name)`
176/// dispatches over them. `#[child]` methods are **not** exposed as Plexus RPC
177/// methods — they contribute only to `ChildRouter` routing.
178///
179/// # Example
180///
181/// ```ignore
182/// #[plexus_macros::activation(namespace = "solar")]
183/// impl Solar {
184/// #[plexus_macros::child]
185/// fn mercury(&self) -> Mercury { self.mercury.clone() }
186///
187/// #[plexus_macros::child]
188/// async fn planet(&self, name: &str) -> Option<Planet> {
189/// self.lookup_planet(name).await
190/// }
191/// }
192/// ```
193///
194/// Used standalone (outside an `#[activation]` block) this attribute is a
195/// no-op that returns the function unchanged.
196#[proc_macro_attribute]
197pub fn child(_attr: TokenStream, item: TokenStream) -> TokenStream {
198 // Outside of #[plexus_macros::activation] this attribute does nothing;
199 // the activation macro strips it and collects the method into the
200 // generated ChildRouter impl.
201 item
202}
203
204/// Companion attribute to Rust's built-in `#[deprecated]` for carrying a
205/// `removed_in = "VERSION"` hint that rustc doesn't recognize.
206///
207/// Rust's `#[deprecated(since = "X", note = "Y")]` supports only `since` and
208/// `note`. This companion attribute supplies the removal version that the
209/// `plexus-macros` codegen folds into the method's `DeprecationInfo.removed_in`
210/// field on the emitted `MethodSchema`.
211///
212/// # Example
213///
214/// ```ignore
215/// #[deprecated(since = "0.5", note = "use `new_method` instead")]
216/// #[plexus_macros::removed_in("0.6")]
217/// #[plexus_macros::method]
218/// async fn legacy(&self) -> impl Stream<Item = String> + Send + 'static {
219/// // ...
220/// }
221/// ```
222///
223/// # Requirements
224///
225/// `#[plexus_macros::removed_in]` is only meaningful when paired with
226/// `#[deprecated]`. The `#[plexus_macros::activation]` macro emits a compile
227/// error when it encounters `#[removed_in]` on a method that doesn't also
228/// carry `#[deprecated]`.
229///
230/// As a standalone attribute (outside an `#[activation]` block) this is a
231/// no-op that returns the item unchanged — the activation macro parses and
232/// strips it during codegen.
233#[proc_macro_attribute]
234pub fn removed_in(attr: TokenStream, item: TokenStream) -> TokenStream {
235 // When `#[plexus_macros::removed_in("X")]` is placed OUTSIDE
236 // `#[plexus_macros::activation(...)]` on the same impl block, the
237 // `removed_in` proc macro runs BEFORE the activation macro (outer
238 // attrs expand first), so a naive no-op implementation would consume
239 // the attribute before the activation macro ever sees it.
240 //
241 // Workaround: emit a synthetic `#[doc(hidden)]` marker attribute on
242 // the item's attr list that encodes the removed_in value. The
243 // activation / method codegen scans for this marker as an equivalent
244 // signal to the companion attribute and uses it to populate
245 // `DeprecationInfo.removed_in`.
246 //
247 // Stable rustc treats `#[doc(hidden)]` as a regular doc attribute and
248 // the sentinel payload rides along in the same literal string.
249 let attr_str: String = match syn::parse::<syn::LitStr>(attr.clone()) {
250 Ok(s) => s.value(),
251 Err(_) => {
252 // Not a bare string literal — just let the item pass through
253 // and let the activation/method layer produce the proper error.
254 return item;
255 }
256 };
257 let marker = format!("__plexus_removed_in:{}", attr_str);
258 let item_ts: proc_macro2::TokenStream = item.into();
259 let marker_lit = proc_macro2::Literal::string(&marker);
260 let out = quote::quote! {
261 #[doc(hidden)]
262 #[doc = #marker_lit]
263 #item_ts
264 };
265 out.into()
266}
267
268fn hub_method_impl(args: HubMethodAttrs, input_fn: ItemFn) -> syn::Result<TokenStream2> {
269 // Extract method name (from attr or function name)
270 let method_name = args
271 .name
272 .unwrap_or_else(|| input_fn.sig.ident.to_string());
273
274 // Resolve description: explicit `description = "..."` wins over `///` doc
275 // comments; if neither is present, description is the empty string.
276 let description = match args.description {
277 Some(explicit) => explicit,
278 None => extract_doc_comment(&input_fn),
279 };
280
281 // Extract input type from first parameter (if any)
282 let input_type = extract_input_type(&input_fn)?;
283
284 // Extract return type
285 let return_type = extract_return_type(&input_fn)?;
286
287 // Function name for the schema function
288 let fn_name = &input_fn.sig.ident;
289 let schema_fn_name = format_ident!("{}_schema", fn_name);
290
291 // Parse crate path
292 let crate_path: syn::Path = syn::parse_str(&args.crate_path)
293 .map_err(|e| syn::Error::new_spanned(&input_fn.sig, format!("Invalid crate_path: {}", e)))?;
294
295 // Generate the schema function
296 let schema_fn = generate_schema_fn(
297 &schema_fn_name,
298 &method_name,
299 &description,
300 input_type.as_ref(),
301 &return_type,
302 &crate_path,
303 );
304
305 // Return the original function plus the schema function
306 Ok(quote! {
307 #input_fn
308
309 #schema_fn
310 })
311}
312
313fn extract_doc_comment(input_fn: &ItemFn) -> String {
314 // Delegate to the shared helper so the standalone `#[method]` macro path
315 // follows the same doc-comment extraction rule as the in-activation path:
316 // lines joined with '\n', common leading whitespace stripped.
317 parse::extract_doc_description(&input_fn.attrs).unwrap_or_default()
318}
319
320fn extract_input_type(input_fn: &ItemFn) -> syn::Result<Option<Type>> {
321 // Skip self parameter, get first real parameter
322 for arg in &input_fn.sig.inputs {
323 match arg {
324 FnArg::Receiver(_) => continue, // Skip &self
325 FnArg::Typed(pat_type) => {
326 // Skip context-like parameters
327 if let Pat::Ident(ident) = &*pat_type.pat {
328 let name = ident.ident.to_string();
329 if name == "ctx" || name == "context" || name == "self_" {
330 continue;
331 }
332 }
333 return Ok(Some((*pat_type.ty).clone()));
334 }
335 }
336 }
337
338 Ok(None)
339}
340
341fn extract_return_type(input_fn: &ItemFn) -> syn::Result<Type> {
342 match &input_fn.sig.output {
343 ReturnType::Default => Err(syn::Error::new_spanned(
344 &input_fn.sig,
345 "hub_method requires a return type",
346 )),
347 ReturnType::Type(_, ty) => Ok((*ty.clone()).clone()),
348 }
349}
350
351fn generate_schema_fn(
352 fn_name: &syn::Ident,
353 method_name: &str,
354 description: &str,
355 input_type: Option<&Type>,
356 return_type: &Type,
357 crate_path: &syn::Path,
358) -> TokenStream2 {
359 let input_schema = if let Some(input_ty) = input_type {
360 quote! {
361 Some(serde_json::to_value(schemars::schema_for!(#input_ty)).unwrap())
362 }
363 } else {
364 quote! { None }
365 };
366
367 let _ = return_type; // Will be used for protocol schema in future
368 let _ = crate_path;
369
370 quote! {
371 /// Generated schema function for this hub method
372 #[allow(dead_code)]
373 pub fn #fn_name() -> serde_json::Value {
374 serde_json::json!({
375 "name": #method_name,
376 "description": #description,
377 "input": #input_schema,
378 })
379 }
380 }
381}
382
383/// Attribute macro for impl blocks defining a Plexus activation.
384///
385/// Generates:
386/// - Method enum for schema extraction
387/// - Activation trait implementation
388/// - RPC server trait and implementation
389///
390/// # Attributes
391///
392/// - `namespace = "..."` (required) - The activation namespace
393/// - `version = "..."` (optional, default: "1.0.0") - Version string
394/// - `description = "..."` (optional) - Activation description
395/// - `crate_path = "..."` (optional, default: "crate") - Path to substrate crate
396///
397/// # Example
398///
399/// ```ignore
400/// #[plexus::activation(namespace = "bash", version = "1.0.0", description = "Execute bash commands")]
401/// impl Bash {
402/// /// Execute a bash command and stream output
403/// #[plexus::method]
404/// async fn execute(&self, command: String) -> impl Stream<Item = BashEvent> + Send + 'static {
405/// self.executor.execute(&command).await
406/// }
407/// }
408/// ```
409#[proc_macro_attribute]
410pub fn activation(attr: TokenStream, item: TokenStream) -> TokenStream {
411 let args = parse_macro_input!(attr as HubMethodsAttrs);
412 let input_impl = parse_macro_input!(item as ItemImpl);
413
414 match generate_all(args, input_impl) {
415 Ok(tokens) => tokens.into(),
416 Err(e) => e.to_compile_error().into(),
417 }
418}
419
420/// Deprecated: use `plexus::activation` instead.
421#[deprecated(since = "0.5.0", note = "Use `plexus::activation` instead")]
422#[proc_macro_attribute]
423pub fn hub_methods(attr: TokenStream, item: TokenStream) -> TokenStream {
424 activation(attr, item)
425}
426
427/// **DEPRECATED**: This derive macro is no longer needed.
428///
429/// With the caller-wraps streaming architecture, event types no longer need to
430/// implement `ActivationStreamItem`. Just use plain domain types with standard
431/// derives:
432///
433/// ```ignore
434/// #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
435/// #[serde(tag = "event", rename_all = "snake_case")]
436/// pub enum MyEvent {
437/// Data { value: String },
438/// Complete { result: i32 },
439/// }
440/// ```
441///
442/// The wrapping happens at the call site via `wrap_stream()`.
443#[deprecated(
444 since = "0.2.0",
445 note = "No longer needed - use plain domain types with Serialize/Deserialize"
446)]
447#[proc_macro_derive(StreamEvent, attributes(stream_event, terminal))]
448pub fn stream_event_derive(input: TokenStream) -> TokenStream {
449 stream_event::derive(input)
450}
451
452/// Derive macro for type-safe handle creation and parsing.
453///
454/// Generates:
455/// - `to_handle(&self) -> Handle` - converts enum variant to Handle
456/// - `impl TryFrom<&Handle>` - parses Handle back to enum variant
457/// - `impl From<EnumName> for Handle` - convenience conversion
458///
459/// # Attributes
460///
461/// Enum-level (required):
462/// - `plugin_id = "CONSTANT_NAME"` - Name of the constant holding the plugin UUID
463/// - `version = "1.0.0"` - Semantic version for handles
464/// - `crate_path = "..."` (optional, default: "plexus_core") - Path to plexus_core crate
465/// - `plugin_id_type = "Type<Args>"` (optional) - Concrete type whose associated
466/// constant `plugin_id` names. Use this when the owning activation is generic
467/// (e.g. `Cone<P: HubContext = NoParent>`) to pin the instantiation and avoid
468/// E0283 "cannot infer type" errors. Pairs with `plugin_id` — e.g.
469/// `plugin_id = "Cone::PLUGIN_ID", plugin_id_type = "Cone<NoParent>"` emits
470/// `<Cone<NoParent>>::PLUGIN_ID`.
471///
472/// Variant-level:
473/// - `method = "..."` (required) - The handle.method value
474/// - `table = "..."` (optional) - SQLite table name (for future resolution)
475/// - `key = "..."` (optional) - Primary key column (for future resolution)
476///
477/// # Example
478///
479/// ```ignore
480/// use hub_macro::HandleEnum;
481/// use uuid::Uuid;
482///
483/// pub const MY_PLUGIN_ID: Uuid = uuid::uuid!("550e8400-e29b-41d4-a716-446655440000");
484///
485/// #[derive(HandleEnum)]
486/// #[handle(plugin_id = "MY_PLUGIN_ID", version = "1.0.0")]
487/// pub enum MyPluginHandle {
488/// #[handle(method = "event", table = "events", key = "id")]
489/// Event { event_id: String },
490///
491/// #[handle(method = "message")]
492/// Message { message_id: String, role: String },
493/// }
494///
495/// // Usage:
496/// let handle_enum = MyPluginHandle::Event { event_id: "evt-123".into() };
497/// let handle: Handle = handle_enum.to_handle();
498/// // handle.method == "event"
499/// // handle.meta == ["evt-123"]
500///
501/// // Parsing back:
502/// let parsed = MyPluginHandle::try_from(&handle)?;
503/// ```
504#[proc_macro_derive(HandleEnum, attributes(handle))]
505pub fn handle_enum_derive(input: TokenStream) -> TokenStream {
506 handle_enum::derive(input)
507}
508
509/// Derive macro for typed HTTP/WebSocket request extraction.
510///
511/// Generates `impl PlexusRequest` (extraction) and `impl schemars::JsonSchema`
512/// (with `x-plexus-source` extensions) for the annotated struct.
513///
514/// # Field annotations
515///
516/// - `#[from_cookie("name")]` — extract from named cookie
517/// - `#[from_header("name")]` — extract from named HTTP header
518/// - `#[from_query("name")]` — extract from named URI query parameter
519/// - `#[from_peer]` — copy `ctx.peer` (peer socket address)
520/// - `#[from_auth_context]` — copy `ctx.auth` (auth context)
521/// - (no annotation) — `Default::default()`
522///
523/// # Example
524///
525/// ```ignore
526/// use plexus_macros::PlexusRequest;
527///
528/// #[derive(PlexusRequest)]
529/// struct MyRequest {
530/// #[from_cookie("access_token")]
531/// auth_token: String,
532///
533/// #[from_header("origin")]
534/// origin: Option<String>,
535///
536/// #[from_peer]
537/// peer_addr: Option<std::net::SocketAddr>,
538/// }
539/// ```
540#[proc_macro_derive(
541 PlexusRequest,
542 attributes(from_cookie, from_header, from_query, from_peer, from_auth_context)
543)]
544pub fn plexus_request_derive(input: TokenStream) -> TokenStream {
545 request::derive(input)
546}
547
548/// No-op `JsonSchema` derive — used when plexus-macros is aliased as `schemars`
549/// in dev-dependencies to prevent duplicate `impl JsonSchema` conflicts with
550/// `#[derive(PlexusRequest)]` (which generates its own `impl JsonSchema`).
551///
552/// When plexus-macros is aliased as schemars in dev-deps:
553/// ```toml
554/// [dev-dependencies]
555/// schemars = { path = "../plexus-macros", package = "plexus-macros", features = ["schemars-compat"] }
556/// ```
557/// then `#[derive(schemars::JsonSchema)]` invokes THIS derive instead of the real schemars derive,
558/// producing no output, so only `PlexusRequest`'s `impl JsonSchema` exists.
559#[cfg(feature = "schemars-compat")]
560#[proc_macro_derive(JsonSchema, attributes(schemars, serde))]
561pub fn json_schema_noop_derive(_input: TokenStream) -> TokenStream {
562 // Intentionally produces no output.
563 // PlexusRequest already generates impl JsonSchema with x-plexus-source extensions.
564 TokenStream::new()
565}