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}