Skip to main content

plexus_macros/
lib.rs

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