Skip to main content

ircbot_macros/
lib.rs

1//! Procedural macros for the [`ircbot`](https://docs.rs/ircbot) framework.
2//!
3//! These macros are re-exported by the `ircbot` crate — refer to its
4//! documentation for usage.
5
6use proc_macro::TokenStream;
7use proc_macro2::{Span, TokenStream as TokenStream2};
8use quote::quote;
9use syn::{
10    parse_macro_input, Expr, ExprLit, FnArg, Ident, ImplItem, ItemImpl, Lit, Meta, Pat, Type,
11};
12
13// ─── Custom parsers ──────────────────────────────────────────────────────────
14
15/// Parses the `#[bot(...)]` attribute arguments.
16///
17/// Currently the only recognised argument is `state = <Type>`; an empty
18/// attribute (`#[bot]`) yields `state: None`.
19struct BotArgs {
20    state: Option<Type>,
21}
22
23impl syn::parse::Parse for BotArgs {
24    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
25        let mut state = None;
26        while !input.is_empty() {
27            let key: Ident = input.parse()?;
28            let _: syn::Token![=] = input.parse()?;
29            if key == "state" {
30                state = Some(input.parse::<Type>()?);
31            } else {
32                return Err(syn::Error::new(
33                    key.span(),
34                    format!("unknown #[bot] argument `{key}` (expected `state`)"),
35                ));
36            }
37            if input.peek(syn::Token![,]) {
38                let _: syn::Token![,] = input.parse()?;
39            }
40        }
41        Ok(BotArgs { state })
42    }
43}
44
45/// Parses `#[command("name")]`, `#[command("name", target = "...")]`, and/or
46/// `#[command("name", role = "...")]`.
47struct CommandArgs {
48    name: String,
49    target: Option<String>,
50    role: Option<String>,
51}
52
53impl syn::parse::Parse for CommandArgs {
54    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
55        let name: syn::LitStr = input.parse()?;
56        let mut target = None;
57        let mut role = None;
58        while input.peek(syn::Token![,]) {
59            let _: syn::Token![,] = input.parse()?;
60            if input.is_empty() {
61                break;
62            }
63            let key: Ident = input.parse()?;
64            let _: syn::Token![=] = input.parse()?;
65            let val: syn::LitStr = input.parse()?;
66            if key == "target" {
67                target = Some(val.value());
68            } else if key == "role" {
69                role = Some(val.value());
70            }
71        }
72        Ok(CommandArgs {
73            name: name.value(),
74            target,
75            role,
76        })
77    }
78}
79
80// ─── #[bot] ──────────────────────────────────────────────────────────────────
81
82/// Derive-like attribute that turns an `impl` block into a runnable IRC bot.
83///
84/// # Custom state
85///
86/// Pass `state = SomeType` to give the bot a public `state` field your handlers
87/// can read:
88///
89/// ```ignore
90/// #[derive(Default)]
91/// struct Counter { hits: std::sync::atomic::AtomicUsize }
92///
93/// #[bot(state = Counter)]
94/// impl MyBot {
95///     #[command("ping")]
96///     async fn ping(&self, ctx: ircbot::Context) -> ircbot::Result {
97///         let n = self.state.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
98///         ctx.reply(format!("pong #{n}"))
99///     }
100/// }
101/// ```
102///
103/// The state type must implement [`Default`] (it is initialised with
104/// `Default::default()` by both `MyBot::default()` and `MyBot::new`) and must be
105/// `Send + Sync + 'static` (the bot is shared across tasks as an `Arc`; that
106/// bound is checked at `main_loop`). Because handlers receive `&self`, mutating
107/// state requires interior mutability — an `AtomicUsize`, a `Mutex<…>`, etc. To
108/// start from a non-default value, assign the public field after constructing:
109/// `let mut bot = MyBot::new(…).await?; bot.state = …;`.
110///
111/// Note: a `SIGHUP` hot-reload re-execs the binary, so in-memory `state` is
112/// reconstructed via `Default` and is **not** carried across the reload.
113///
114/// This is sugar over the lower-level API: a bot is any
115/// `Arc<T: Send + Sync + 'static>` passed to `ircbot::internal::run_bot` with a
116/// hand-built `Vec<ircbot::HandlerEntry<T>>`, which you can use directly when you
117/// want full control over the bot type.
118///
119/// # Panics
120///
121/// Panics at compile time if the annotated `impl` block does not use a simple
122/// (non-generic, non-path) type name, e.g. `impl MyBot { … }`.
123#[allow(clippy::too_many_lines)]
124#[proc_macro_attribute]
125pub fn bot(attr: TokenStream, item: TokenStream) -> TokenStream {
126    let args = parse_macro_input!(attr as BotArgs);
127    let input = parse_macro_input!(item as ItemImpl);
128
129    let self_ty = &input.self_ty;
130    let struct_name = match self_ty.as_ref() {
131        Type::Path(tp) => tp
132            .path
133            .get_ident()
134            .cloned()
135            .expect("#[bot] expects a simple struct name"),
136        _ => panic!("#[bot] expects a simple struct name"),
137    };
138
139    let mut handler_entries: Vec<TokenStream2> = Vec::new();
140    let mut cleaned_methods: Vec<TokenStream2> = Vec::new();
141
142    for item in &input.items {
143        if let ImplItem::Fn(method) = item {
144            let method_name = &method.sig.ident;
145
146            // Extra args beyond &self and ctx, retaining the full parsed type so
147            // command handlers can parse typed positional arguments.
148            let extra_args: Vec<(Ident, Type)> = method
149                .sig
150                .inputs
151                .iter()
152                .skip(2)
153                .filter_map(|arg| {
154                    if let FnArg::Typed(pt) = arg {
155                        let name = match pt.pat.as_ref() {
156                            Pat::Ident(pi) => pi.ident.clone(),
157                            _ => Ident::new("arg", Span::call_site()),
158                        };
159                        Some((name, (*pt.ty).clone()))
160                    } else {
161                        None
162                    }
163                })
164                .collect();
165
166            let mut trigger_tokens: Option<TokenStream2> = None;
167            // The command keyword, if this handler is triggered by a command
168            // (via `#[command]` or `#[on(command = "...")]`). Drives typed
169            // argument parsing and the generated usage string.
170            let mut command_name: Option<String> = None;
171            let mut cleaned_attrs: Vec<syn::Attribute> = Vec::new();
172
173            for attr in &method.attrs {
174                let Some(ident) = attr.path().get_ident() else {
175                    cleaned_attrs.push(attr.clone());
176                    continue;
177                };
178
179                match ident.to_string().as_str() {
180                    "command" => {
181                        if let Meta::List(ml) = &attr.meta {
182                            let args: CommandArgs =
183                                syn::parse2(ml.tokens.clone()).unwrap_or(CommandArgs {
184                                    name: String::new(),
185                                    target: None,
186                                    role: None,
187                                });
188                            let name = &args.name;
189                            command_name = Some(args.name.clone());
190                            let target_ts = opt_str_ts(args.target.as_deref());
191                            let role_ts = opt_str_ts(args.role.as_deref());
192                            trigger_tokens = Some(quote! {
193                                ircbot::Trigger::Command {
194                                    name: #name.to_string(),
195                                    target: #target_ts,
196                                    role: #role_ts,
197                                }
198                            });
199                        }
200                    }
201                    "on" => {
202                        if let Meta::List(ml) = &attr.meta {
203                            let metas_result = ml.parse_args_with(
204                                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
205                            );
206
207                            let mut event: Option<String> = None;
208                            let mut message: Option<String> = None;
209                            let mut command_on: Option<String> = None;
210                            let mut target: Option<String> = None;
211                            let mut regex: Option<String> = None;
212                            let mut mention = false;
213                            let mut cron_interval: Option<String> = None;
214                            let mut cron_tz: Option<String> = None;
215                            let mut role: Option<String> = None;
216
217                            if let Ok(metas) = metas_result {
218                                for meta in metas {
219                                    match &meta {
220                                        Meta::Path(p) if p.is_ident("mention") => {
221                                            mention = true;
222                                        }
223                                        Meta::NameValue(nv) => {
224                                            let k = nv
225                                                .path
226                                                .get_ident()
227                                                .map(ToString::to_string)
228                                                .unwrap_or_default();
229                                            if let Expr::Lit(ExprLit {
230                                                lit: Lit::Str(s), ..
231                                            }) = &nv.value
232                                            {
233                                                let v = s.value();
234                                                match k.as_str() {
235                                                    "event" => event = Some(v),
236                                                    "message" => message = Some(v),
237                                                    "command" => command_on = Some(v),
238                                                    "target" => target = Some(v),
239                                                    "regex" => regex = Some(v),
240                                                    "cron" => cron_interval = Some(v),
241                                                    "tz" => cron_tz = Some(v),
242                                                    "role" => role = Some(v),
243                                                    _ => {}
244                                                }
245                                            }
246                                        }
247                                        _ => {}
248                                    }
249                                }
250                            }
251
252                            let target_ts = opt_str_ts(target.as_deref());
253                            let role_ts = opt_str_ts(role.as_deref());
254                            // Precedence: message > command > event > mention > cron.
255                            // Only the first matching key wins; combining multiple
256                            // trigger types in one `#[on(...)]` is not supported.
257                            if let Some(msg_pat) = message {
258                                trigger_tokens = Some(quote! {
259                                    ircbot::Trigger::Message {
260                                        pattern: #msg_pat.to_string(),
261                                        target: #target_ts,
262                                    }
263                                });
264                            } else if let Some(cmd) = command_on {
265                                command_name = Some(cmd.clone());
266                                trigger_tokens = Some(quote! {
267                                    ircbot::Trigger::Command {
268                                        name: #cmd.to_string(),
269                                        target: #target_ts,
270                                        role: #role_ts,
271                                    }
272                                });
273                            } else if let Some(ev) = event {
274                                let regex_ts = opt_str_ts(regex.as_deref());
275                                trigger_tokens = Some(quote! {
276                                    ircbot::Trigger::Event {
277                                        event: #ev.to_string(),
278                                        target: #target_ts,
279                                        regex: #regex_ts,
280                                    }
281                                });
282                            } else if mention {
283                                trigger_tokens = Some(quote! {
284                                    ircbot::Trigger::Mention {
285                                        target: #target_ts,
286                                    }
287                                });
288                            } else if let Some(cron_str) = cron_interval {
289                                // Validate the cron expression at compile time.
290                                if let Err(e) = cron_str.parse::<cron::Schedule>() {
291                                    panic!(
292                                        "invalid cron expression {cron_str:?}: {e}\n\
293                                         \n\
294                                         The expression must use the 6-field Quartz format \
295                                         with an optional 7th year field:\n\
296                                         \n\
297                                         sec  min  hour  day-of-month  month  day-of-week  [year]\n\
298                                         \n\
299                                         Examples:\n\
300                                         \"0 0 * * * *\"          every hour (on the minute)\n\
301                                         \"0 0 8-16 * * MON-FRI\" top of each hour, 8 a.m.–4 p.m., weekdays\n\
302                                         \"0 */15 * * * *\"        every 15 minutes\n\
303                                         \"0 0 9 * * MON\"         every Monday at 9 a.m."
304                                    );
305                                }
306                                // Validate the timezone at compile time (defaults to UTC).
307                                let tz_str = cron_tz.as_deref().unwrap_or("UTC");
308                                if let Err(e) = tz_str.parse::<chrono_tz::Tz>() {
309                                    panic!(
310                                        "invalid timezone {tz_str:?}: {e}\n\
311                                         \n\
312                                         Use an IANA timezone name such as:\n\
313                                         \"UTC\", \"America/New_York\", \"Europe/London\", \
314                                         \"Asia/Tokyo\""
315                                    );
316                                }
317                                let tz_str = tz_str.to_string();
318                                trigger_tokens = Some(quote! {
319                                    ircbot::Trigger::Cron {
320                                        schedule: #cron_str.to_string(),
321                                        tz: #tz_str.to_string(),
322                                        target: #target_ts,
323                                    }
324                                });
325                            }
326                        }
327                    }
328                    _ => {
329                        cleaned_attrs.push(attr.clone());
330                    }
331                }
332            }
333
334            if let Some(trigger) = trigger_tokens {
335                let wrapper = build_wrapper(method_name, &extra_args, command_name.as_deref());
336                handler_entries.push(quote! {
337                    ircbot::HandlerEntry {
338                        trigger: #trigger,
339                        handler: std::boxed::Box::new(#wrapper),
340                    }
341                });
342
343                let mut cleaned = method.clone();
344                cleaned.attrs = cleaned_attrs;
345                cleaned_methods.push(quote! { #cleaned });
346            } else {
347                cleaned_methods.push(quote! { #method });
348            }
349        } else {
350            let it = item;
351            cleaned_methods.push(quote! { #it });
352        }
353    }
354
355    // Optional user state field. When `state = Type` is absent both fragments are
356    // empty, so the generated tokens are identical to the no-state case. The init
357    // fragment carries a leading comma because the `__state` field in the struct
358    // literals below has no trailing comma.
359    let state_field_decl = match &args.state {
360        Some(ty) => quote! { pub state: #ty, },
361        None => quote! {},
362    };
363    let state_field_init = match &args.state {
364        Some(_) => quote! { , state: std::default::Default::default() },
365        None => quote! {},
366    };
367    // A constructor that takes a pre-built state and attaches no live
368    // connection. Only meaningful when the bot has a `state` field, so it is
369    // emitted solely in the `state = Type` case. This is the supported entry
370    // point for unit-testing handlers (see `ircbot::testing`): it bypasses the
371    // `Default` impl, which would build state via `Default::default()` — wrong
372    // for any state that opens files, sockets, or other real resources.
373    let from_state_method = match &args.state {
374        Some(ty) => quote! {
375            /// Construct the bot from a pre-built `state`, with no live IRC
376            /// connection attached.
377            ///
378            /// This is the intended way to unit-test handlers. Handlers take
379            /// `&self` and reach the connection only when they send a reply,
380            /// which in tests is captured by a
381            /// [`TestContext`](ircbot::testing::TestContext) instead — so a bot
382            /// built this way can drive handlers directly without ever touching
383            /// the network.
384            ///
385            /// Prefer this over [`Default::default`] whenever your state type's
386            /// `Default` does real work (opening a database, reading config,
387            /// connecting to a service): `from_state` lets the test build a
388            /// purpose-made state — an in-memory store, a temp-dir fixture —
389            /// and inject it directly.
390            ///
391            /// # Example
392            ///
393            /// ```rust,no_run
394            /// # use ircbot::{bot, Context, Result};
395            /// # use ircbot::testing::TestContext;
396            /// #[derive(Default)]
397            /// struct State { greeting: String }
398            ///
399            /// #[bot(state = State)]
400            /// impl Greeter {
401            ///     #[on(mention)]
402            ///     async fn hello(&self, ctx: Context, _text: String) -> Result {
403            ///         ctx.reply(self.state.greeting.clone())
404            ///     }
405            /// }
406            ///
407            /// #[tokio::test]
408            /// async fn replies_with_configured_greeting() {
409            ///     let bot = Greeter::from_state(State { greeting: "hi!".into() });
410            ///     let mut tc = TestContext::channel("#test", "alice", "greeter: yo");
411            ///     bot.hello(tc.take_ctx(), "yo".into()).await.unwrap();
412            ///     // `reply` prefixes the sender's nick in a channel.
413            ///     assert_eq!(tc.next_reply().as_deref(), Some("PRIVMSG #test :alice, hi!\r\n"));
414            /// }
415            /// ```
416            pub fn from_state(state: #ty) -> Self {
417                #struct_name { __state: std::option::Option::None, state }
418            }
419        },
420        None => quote! {},
421    };
422
423    quote! {
424        pub struct #struct_name {
425            __state: std::option::Option<ircbot::State>,
426            #state_field_decl
427        }
428
429        impl Default for #struct_name {
430            fn default() -> Self {
431                #struct_name { __state: std::option::Option::None #state_field_init }
432            }
433        }
434
435        impl #struct_name {
436            /// Connect to an IRC server and return a bot ready to run.
437            ///
438            /// `server` is anything that converts into an
439            /// [`ircbot::Server`](ircbot::Server). A bare `"host:port"` string
440            /// connects in plaintext; with the `tls` feature, `Server::tls`
441            /// connects over TLS:
442            ///
443            /// ```rust,ignore
444            /// MyBot::new("mybot", "irc.example.net:6667", ["rust"]).await?;
445            /// MyBot::new("mybot", Server::tls("irc.libera.chat:6697"), ["rust"]).await?;
446            /// ```
447            ///
448            /// On Unix, if this process was started by `exec_reload` the live
449            /// TCP connection is inherited from the parent binary and no new
450            /// connection is made.  The `nick`, `server`, and `channels`
451            /// arguments are used only when no inherited connection is present.
452            /// A TLS connection is never inherited, so a reloaded TLS bot always
453            /// reconnects using the `server` given here.
454            pub async fn new(
455                nick: impl Into<String>,
456                server: impl Into<ircbot::Server>,
457                channels: impl IntoIterator<Item = impl Into<String>>,
458            ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
459                // On Unix, check for an inherited fd from a hot-reload exec.
460                #[cfg(unix)]
461                if let Some(state) = ircbot::State::try_inherit_from_env()? {
462                    eprintln!("[ircbot] hot-reload: resumed on inherited connection");
463                    return Ok(#struct_name { __state: Some(state) #state_field_init });
464                }
465
466                let state = ircbot::State::connect(
467                    nick.into(),
468                    server,
469                    channels.into_iter().map(|c| ircbot::Channel::from(c.into())).collect(),
470                ).await?;
471                Ok(#struct_name { __state: Some(state) #state_field_init })
472            }
473
474            #from_state_method
475
476            /// Set a custom CTCP `VERSION` reply.
477            ///
478            /// By default the bot answers CTCP `VERSION` with
479            /// `ircbot <crate-version>`. Call this (before `main_loop`) to reply
480            /// with your own identifier instead. The value is re-applied on a
481            /// `SIGHUP` hot-reload, since the builder runs again on startup.
482            #[must_use]
483            pub fn with_ctcp_version(mut self, version: impl Into<String>) -> Self {
484                if let Some(state) = self.__state.take() {
485                    self.__state = Some(state.with_ctcp_version(version));
486                }
487                self
488            }
489
490            /// Enable keepnick: periodically re-attempt to reclaim the
491            /// originally-requested nick whenever the bot is using a different
492            /// one. Disabled by default. Call this (before `main_loop`); the
493            /// value is re-applied on a `SIGHUP` hot-reload, since the builder
494            /// runs again on startup.
495            #[must_use]
496            pub fn with_keepnick_interval(mut self, interval: std::time::Duration) -> Self {
497                if let Some(state) = self.__state.take() {
498                    self.__state = Some(state.with_keepnick_interval(interval));
499                }
500                self
501            }
502
503            /// Enable keepnick with the default reclaim interval
504            /// (60 seconds). Convenience wrapper around
505            /// `with_keepnick_interval`.
506            #[must_use]
507            pub fn with_keepnick(mut self) -> Self {
508                if let Some(state) = self.__state.take() {
509                    self.__state = Some(state.with_keepnick());
510                }
511                self
512            }
513
514            /// Define an access-control role named `name`, authorising any
515            /// sender whose `nick!user@host` matches one of the given hostmask
516            /// glob patterns (`*` wildcard). Commands annotated with
517            /// `#[command(..., role = #name)]` only fire for matching senders;
518            /// everyone else is silently ignored.
519            ///
520            /// Call this (before `main_loop`); like the other builders it is
521            /// re-applied on a `SIGHUP` hot-reload, since the builder runs again
522            /// on startup. May be called repeatedly to add patterns or roles.
523            #[must_use]
524            pub fn with_role(
525                mut self,
526                name: impl Into<String>,
527                masks: impl IntoIterator<Item = impl Into<String>>,
528            ) -> Self {
529                if let Some(state) = self.__state.take() {
530                    self.__state = Some(state.with_role(name, masks));
531                }
532                self
533            }
534
535            /// Run the bot's main event loop.
536            ///
537            /// On Unix, listens for `SIGHUP`.  When received, the current
538            /// process execs the bot binary at the same path, passing the live
539            /// TCP socket fd to the new process so the IRC connection is never
540            /// interrupted.  If the exec fails the bot continues running.
541            pub async fn main_loop(mut self) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
542                let state = self.__state.take().expect("bot already started");
543
544                #[cfg(unix)]
545                let (raw_fd, reload_nick, reload_server, reload_channels,
546                     reload_ka_interval_ms, reload_ka_timeout_ms) = (
547                    state.raw_fd,
548                    state.nick.as_str().to_string(),
549                    state.server.addr().to_string(),
550                    state.channels.iter().map(|c| c.as_str().to_string()).collect::<std::vec::Vec<String>>(),
551                    state.keepalive_interval().as_millis() as u64,
552                    state.keepalive_timeout().as_millis() as u64,
553                );
554
555                let bot_arc = std::sync::Arc::new(self);
556
557                // Install a SIGHUP listener that execs the new binary with the
558                // live fd inherited — zero-disconnect binary hot-reload.
559                #[cfg(unix)]
560                {
561                    tokio::spawn(async move {
562                        use tokio::signal::unix::{signal, SignalKind};
563                        match signal(SignalKind::hangup()) {
564                            Ok(mut stream) => {
565                                while stream.recv().await.is_some() {
566                                    eprintln!("[ircbot] SIGHUP — hot-reload: exec new binary");
567                                    let err = ircbot::hot_reload::exec_reload(
568                                        raw_fd,
569                                        &reload_nick,
570                                        &reload_server,
571                                        &reload_channels,
572                                        reload_ka_interval_ms,
573                                        reload_ka_timeout_ms,
574                                    );
575                                    // exec_reload only returns on failure.
576                                    eprintln!("[ircbot] hot-reload exec failed: {err}");
577                                }
578                            }
579                            Err(e) => {
580                                eprintln!("[ircbot] failed to install SIGHUP handler: {e}");
581                            }
582                        }
583                    });
584                }
585
586                ircbot::internal::run_bot(bot_arc, state, #struct_name::__handlers()).await
587            }
588
589            fn __handlers() -> Vec<ircbot::HandlerEntry<#struct_name>> {
590                vec![ #(#handler_entries),* ]
591            }
592
593            #(#cleaned_methods)*
594        }
595    }
596    .into()
597}
598
599// ─── helpers ─────────────────────────────────────────────────────────────────
600
601fn opt_str_ts(s: Option<&str>) -> TokenStream2 {
602    if let Some(v) = s {
603        quote! { Some(#v.to_string()) }
604    } else {
605        quote! { None }
606    }
607}
608
609/// How a handler parameter's declared type is sourced from a message.
610enum TypeClass {
611    /// The message sender (`User`).
612    User,
613    /// A `String`.
614    StringTy,
615    /// `Option<Inner>`; `is_string` is true for `Option<String>`.
616    Opt { inner: Type, is_string: bool },
617    /// `Vec<Inner>`; `is_string` is true for `Vec<String>`.
618    VecTy { inner: Type, is_string: bool },
619    /// Any other type, parsed from a single token via `FromStr`.
620    Scalar(Type),
621}
622
623/// The last path segment of a `Type::Path` (e.g. `Option` of `std::option::Option`).
624fn type_last_seg(ty: &Type) -> Option<&syn::PathSegment> {
625    match ty {
626        Type::Path(tp) => tp.path.segments.last(),
627        _ => None,
628    }
629}
630
631/// Whether `ty`'s final path segment is the identifier `name`.
632fn type_is(ty: &Type, name: &str) -> bool {
633    type_last_seg(ty).is_some_and(|s| s.ident == name)
634}
635
636/// The first generic type argument of `ty` (e.g. `i64` of `Option<i64>`).
637fn generic_inner(ty: &Type) -> Option<Type> {
638    let seg = type_last_seg(ty)?;
639    if let syn::PathArguments::AngleBracketed(ab) = &seg.arguments {
640        for arg in &ab.args {
641            if let syn::GenericArgument::Type(t) = arg {
642                return Some(t.clone());
643            }
644        }
645    }
646    None
647}
648
649/// Classify a parameter type for argument extraction.
650fn classify(ty: &Type) -> TypeClass {
651    if type_is(ty, "User") {
652        return TypeClass::User;
653    }
654    if type_is(ty, "String") {
655        return TypeClass::StringTy;
656    }
657    if type_is(ty, "Option") {
658        if let Some(inner) = generic_inner(ty) {
659            let is_string = type_is(&inner, "String");
660            return TypeClass::Opt { inner, is_string };
661        }
662    }
663    if type_is(ty, "Vec") {
664        if let Some(inner) = generic_inner(ty) {
665            let is_string = type_is(&inner, "String");
666            return TypeClass::VecTy { inner, is_string };
667        }
668    }
669    TypeClass::Scalar(ty.clone())
670}
671
672fn build_wrapper(
673    method_name: &Ident,
674    extra_args: &[(Ident, Type)],
675    command_name: Option<&str>,
676) -> TokenStream2 {
677    if extra_args.is_empty() {
678        return quote! {
679            |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
680                std::boxed::Box::pin(async move { bot.#method_name(ctx).await })
681            }
682        };
683    }
684
685    let call_args: Vec<TokenStream2> = extra_args
686        .iter()
687        .map(|(name, _)| quote! { #name })
688        .collect();
689
690    let extractions: Vec<TokenStream2> = if let Some(cmd) = command_name {
691        command_extractions(extra_args, cmd)
692    } else {
693        legacy_extractions(extra_args)
694    };
695
696    quote! {
697        |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
698            std::boxed::Box::pin(async move {
699                #(#extractions)*
700                bot.#method_name(ctx, #(#call_args),*).await
701            })
702        }
703    }
704}
705
706/// Argument extraction for non-command triggers (message/event/mention).
707///
708/// Preserves historical behaviour: each `String` parameter maps to the trigger
709/// capture group at its positional index, `User` becomes the sender, and any
710/// other type is filled with `Default::default()`.
711fn legacy_extractions(extra_args: &[(Ident, Type)]) -> Vec<TokenStream2> {
712    let mut out = Vec::new();
713    let mut str_idx = 0usize;
714    for (name, ty) in extra_args {
715        match classify(ty) {
716            TypeClass::User => out.push(quote! {
717                let #name = ctx.sender.clone().unwrap_or_default();
718            }),
719            TypeClass::StringTy => {
720                let idx = str_idx;
721                str_idx += 1;
722                out.push(quote! {
723                    let #name: String = if !ctx.captures.is_empty() {
724                        ctx.captures.get(#idx).cloned().unwrap_or_default()
725                    } else {
726                        ctx.message_text().to_string()
727                    };
728                });
729            }
730            _ => out.push(quote! {
731                let #name: #ty = std::default::Default::default();
732            }),
733        }
734    }
735    out
736}
737
738/// Argument extraction for command triggers: typed positional parsing of the
739/// command tail, replying with a generated usage string (and skipping the
740/// handler) when a required argument is missing or fails to parse.
741fn command_extractions(extra_args: &[(Ident, Type)], cmd: &str) -> Vec<TokenStream2> {
742    // The last argument sourced from the tail (everything except `User`); a
743    // trailing `String` here captures the rest of the line.
744    let last_tail_idx = extra_args.iter().rposition(|(_, ty)| !type_is(ty, "User"));
745
746    // Build the usage string from the signature.
747    let mut usage_parts: Vec<String> = Vec::new();
748    for (name, ty) in extra_args {
749        match classify(ty) {
750            TypeClass::User => {}
751            TypeClass::Opt { .. } => usage_parts.push(format!("[{name}]")),
752            TypeClass::VecTy { .. } => usage_parts.push(format!("[{name}...]")),
753            _ => usage_parts.push(format!("<{name}>")),
754        }
755    }
756    let usage = if usage_parts.is_empty() {
757        format!("usage: !{cmd}")
758    } else {
759        format!("usage: !{cmd} {}", usage_parts.join(" "))
760    };
761    let usage_fail = quote! {
762        { let _ = ctx.reply(#usage); return std::result::Result::Ok(()); }
763    };
764
765    // `next_token` needs `&mut __args`; the rest-consuming helpers take `self`.
766    // Only declare `__args` mutable when a token is actually pulled, to avoid an
767    // `unused_mut` warning under `-D warnings`.
768    let needs_mut = extra_args.iter().enumerate().any(|(i, (_, ty))| {
769        let is_last_tail = Some(i) == last_tail_idx;
770        match classify(ty) {
771            TypeClass::User => false,
772            TypeClass::StringTy => !is_last_tail,
773            TypeClass::Scalar(_) => true,
774            TypeClass::Opt { is_string, .. } => !is_string,
775            TypeClass::VecTy { .. } => false,
776        }
777    });
778    let has_tail_args = extra_args.iter().any(|(_, ty)| !type_is(ty, "User"));
779
780    let mut out: Vec<TokenStream2> = Vec::new();
781    if has_tail_args {
782        let binding = if needs_mut {
783            quote! { let mut __args = ircbot::internal::Args::new(&__tail); }
784        } else {
785            quote! { let __args = ircbot::internal::Args::new(&__tail); }
786        };
787        out.push(quote! {
788            let __tail: String = ctx.captures.first().cloned().unwrap_or_default();
789            #binding
790        });
791    }
792
793    for (i, (name, ty)) in extra_args.iter().enumerate() {
794        let is_last_tail = Some(i) == last_tail_idx;
795        match classify(ty) {
796            TypeClass::User => out.push(quote! {
797                let #name = ctx.sender.clone().unwrap_or_default();
798            }),
799            TypeClass::StringTy if is_last_tail => out.push(quote! {
800                let #name: String = __args.rest().to_string();
801            }),
802            TypeClass::StringTy => out.push(quote! {
803                let #name: String = match __args.next_token() {
804                    Some(t) => t.to_string(),
805                    None => #usage_fail,
806                };
807            }),
808            TypeClass::Scalar(scalar) => out.push(quote! {
809                let #name: #scalar = match __args.next_token() {
810                    Some(t) => match t.parse::<#scalar>() {
811                        Ok(v) => v,
812                        Err(_) => #usage_fail,
813                    },
814                    None => #usage_fail,
815                };
816            }),
817            TypeClass::Opt {
818                is_string: true, ..
819            } => out.push(quote! {
820                let #name: Option<String> = {
821                    let __r = __args.rest();
822                    if __r.is_empty() { None } else { Some(__r.to_string()) }
823                };
824            }),
825            TypeClass::Opt { inner, .. } => out.push(quote! {
826                let #name: Option<#inner> = match __args.next_token() {
827                    Some(t) => match t.parse::<#inner>() {
828                        Ok(v) => Some(v),
829                        Err(_) => #usage_fail,
830                    },
831                    None => None,
832                };
833            }),
834            TypeClass::VecTy {
835                is_string: true, ..
836            } => out.push(quote! {
837                let #name: Vec<String> = __args.rest_tokens();
838            }),
839            TypeClass::VecTy { inner, .. } => out.push(quote! {
840                let #name: Vec<#inner> = {
841                    let mut __out: Vec<#inner> = std::vec::Vec::new();
842                    for __t in __args.rest_tokens() {
843                        match __t.parse::<#inner>() {
844                            Ok(v) => __out.push(v),
845                            Err(_) => #usage_fail,
846                        }
847                    }
848                    __out
849                };
850            }),
851        }
852    }
853    out
854}
855
856// ─── #[command] / #[on] as standalone no-ops ─────────────────────────────────
857
858#[doc = include_str!("../docs/command.md")]
859#[proc_macro_attribute]
860pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
861    item
862}
863
864#[doc = include_str!("../docs/on.md")]
865#[proc_macro_attribute]
866pub fn on(_attr: TokenStream, item: TokenStream) -> TokenStream {
867    item
868}