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            /// On Unix, if this process was started by `exec_reload` the live
439            /// TCP connection is inherited from the parent binary and no new
440            /// connection is made.  The `nick`, `server`, and `channels`
441            /// arguments are used only when no inherited connection is present.
442            pub async fn new(
443                nick: impl Into<String>,
444                server: impl AsRef<str>,
445                channels: impl IntoIterator<Item = impl Into<String>>,
446            ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
447                // On Unix, check for an inherited fd from a hot-reload exec.
448                #[cfg(unix)]
449                if let Some(state) = ircbot::State::try_inherit_from_env()? {
450                    eprintln!("[ircbot] hot-reload: resumed on inherited connection");
451                    return Ok(#struct_name { __state: Some(state) #state_field_init });
452                }
453
454                let state = ircbot::State::connect(
455                    nick.into(),
456                    server.as_ref(),
457                    channels.into_iter().map(|c| ircbot::Channel::from(c.into())).collect(),
458                ).await?;
459                Ok(#struct_name { __state: Some(state) #state_field_init })
460            }
461
462            #from_state_method
463
464            /// Set a custom CTCP `VERSION` reply.
465            ///
466            /// By default the bot answers CTCP `VERSION` with
467            /// `ircbot <crate-version>`. Call this (before `main_loop`) to reply
468            /// with your own identifier instead. The value is re-applied on a
469            /// `SIGHUP` hot-reload, since the builder runs again on startup.
470            #[must_use]
471            pub fn with_ctcp_version(mut self, version: impl Into<String>) -> Self {
472                if let Some(state) = self.__state.take() {
473                    self.__state = Some(state.with_ctcp_version(version));
474                }
475                self
476            }
477
478            /// Enable keepnick: periodically re-attempt to reclaim the
479            /// originally-requested nick whenever the bot is using a different
480            /// one. Disabled by default. Call this (before `main_loop`); the
481            /// value is re-applied on a `SIGHUP` hot-reload, since the builder
482            /// runs again on startup.
483            #[must_use]
484            pub fn with_keepnick_interval(mut self, interval: std::time::Duration) -> Self {
485                if let Some(state) = self.__state.take() {
486                    self.__state = Some(state.with_keepnick_interval(interval));
487                }
488                self
489            }
490
491            /// Enable keepnick with the default reclaim interval
492            /// (60 seconds). Convenience wrapper around
493            /// `with_keepnick_interval`.
494            #[must_use]
495            pub fn with_keepnick(mut self) -> Self {
496                if let Some(state) = self.__state.take() {
497                    self.__state = Some(state.with_keepnick());
498                }
499                self
500            }
501
502            /// Define an access-control role named `name`, authorising any
503            /// sender whose `nick!user@host` matches one of the given hostmask
504            /// glob patterns (`*` wildcard). Commands annotated with
505            /// `#[command(..., role = #name)]` only fire for matching senders;
506            /// everyone else is silently ignored.
507            ///
508            /// Call this (before `main_loop`); like the other builders it is
509            /// re-applied on a `SIGHUP` hot-reload, since the builder runs again
510            /// on startup. May be called repeatedly to add patterns or roles.
511            #[must_use]
512            pub fn with_role(
513                mut self,
514                name: impl Into<String>,
515                masks: impl IntoIterator<Item = impl Into<String>>,
516            ) -> Self {
517                if let Some(state) = self.__state.take() {
518                    self.__state = Some(state.with_role(name, masks));
519                }
520                self
521            }
522
523            /// Run the bot's main event loop.
524            ///
525            /// On Unix, listens for `SIGHUP`.  When received, the current
526            /// process execs the bot binary at the same path, passing the live
527            /// TCP socket fd to the new process so the IRC connection is never
528            /// interrupted.  If the exec fails the bot continues running.
529            pub async fn main_loop(mut self) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
530                let state = self.__state.take().expect("bot already started");
531
532                #[cfg(unix)]
533                let (raw_fd, reload_nick, reload_server, reload_channels,
534                     reload_ka_interval_ms, reload_ka_timeout_ms) = (
535                    state.raw_fd,
536                    state.nick.as_str().to_string(),
537                    state.server.clone(),
538                    state.channels.iter().map(|c| c.as_str().to_string()).collect::<std::vec::Vec<String>>(),
539                    state.keepalive_interval().as_millis() as u64,
540                    state.keepalive_timeout().as_millis() as u64,
541                );
542
543                let bot_arc = std::sync::Arc::new(self);
544
545                // Install a SIGHUP listener that execs the new binary with the
546                // live fd inherited — zero-disconnect binary hot-reload.
547                #[cfg(unix)]
548                {
549                    tokio::spawn(async move {
550                        use tokio::signal::unix::{signal, SignalKind};
551                        match signal(SignalKind::hangup()) {
552                            Ok(mut stream) => {
553                                while stream.recv().await.is_some() {
554                                    eprintln!("[ircbot] SIGHUP — hot-reload: exec new binary");
555                                    let err = ircbot::hot_reload::exec_reload(
556                                        raw_fd,
557                                        &reload_nick,
558                                        &reload_server,
559                                        &reload_channels,
560                                        reload_ka_interval_ms,
561                                        reload_ka_timeout_ms,
562                                    );
563                                    // exec_reload only returns on failure.
564                                    eprintln!("[ircbot] hot-reload exec failed: {err}");
565                                }
566                            }
567                            Err(e) => {
568                                eprintln!("[ircbot] failed to install SIGHUP handler: {e}");
569                            }
570                        }
571                    });
572                }
573
574                ircbot::internal::run_bot(bot_arc, state, #struct_name::__handlers()).await
575            }
576
577            fn __handlers() -> Vec<ircbot::HandlerEntry<#struct_name>> {
578                vec![ #(#handler_entries),* ]
579            }
580
581            #(#cleaned_methods)*
582        }
583    }
584    .into()
585}
586
587// ─── helpers ─────────────────────────────────────────────────────────────────
588
589fn opt_str_ts(s: Option<&str>) -> TokenStream2 {
590    if let Some(v) = s {
591        quote! { Some(#v.to_string()) }
592    } else {
593        quote! { None }
594    }
595}
596
597/// How a handler parameter's declared type is sourced from a message.
598enum TypeClass {
599    /// The message sender (`User`).
600    User,
601    /// A `String`.
602    StringTy,
603    /// `Option<Inner>`; `is_string` is true for `Option<String>`.
604    Opt { inner: Type, is_string: bool },
605    /// `Vec<Inner>`; `is_string` is true for `Vec<String>`.
606    VecTy { inner: Type, is_string: bool },
607    /// Any other type, parsed from a single token via `FromStr`.
608    Scalar(Type),
609}
610
611/// The last path segment of a `Type::Path` (e.g. `Option` of `std::option::Option`).
612fn type_last_seg(ty: &Type) -> Option<&syn::PathSegment> {
613    match ty {
614        Type::Path(tp) => tp.path.segments.last(),
615        _ => None,
616    }
617}
618
619/// Whether `ty`'s final path segment is the identifier `name`.
620fn type_is(ty: &Type, name: &str) -> bool {
621    type_last_seg(ty).is_some_and(|s| s.ident == name)
622}
623
624/// The first generic type argument of `ty` (e.g. `i64` of `Option<i64>`).
625fn generic_inner(ty: &Type) -> Option<Type> {
626    let seg = type_last_seg(ty)?;
627    if let syn::PathArguments::AngleBracketed(ab) = &seg.arguments {
628        for arg in &ab.args {
629            if let syn::GenericArgument::Type(t) = arg {
630                return Some(t.clone());
631            }
632        }
633    }
634    None
635}
636
637/// Classify a parameter type for argument extraction.
638fn classify(ty: &Type) -> TypeClass {
639    if type_is(ty, "User") {
640        return TypeClass::User;
641    }
642    if type_is(ty, "String") {
643        return TypeClass::StringTy;
644    }
645    if type_is(ty, "Option") {
646        if let Some(inner) = generic_inner(ty) {
647            let is_string = type_is(&inner, "String");
648            return TypeClass::Opt { inner, is_string };
649        }
650    }
651    if type_is(ty, "Vec") {
652        if let Some(inner) = generic_inner(ty) {
653            let is_string = type_is(&inner, "String");
654            return TypeClass::VecTy { inner, is_string };
655        }
656    }
657    TypeClass::Scalar(ty.clone())
658}
659
660fn build_wrapper(
661    method_name: &Ident,
662    extra_args: &[(Ident, Type)],
663    command_name: Option<&str>,
664) -> TokenStream2 {
665    if extra_args.is_empty() {
666        return quote! {
667            |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
668                std::boxed::Box::pin(async move { bot.#method_name(ctx).await })
669            }
670        };
671    }
672
673    let call_args: Vec<TokenStream2> = extra_args
674        .iter()
675        .map(|(name, _)| quote! { #name })
676        .collect();
677
678    let extractions: Vec<TokenStream2> = if let Some(cmd) = command_name {
679        command_extractions(extra_args, cmd)
680    } else {
681        legacy_extractions(extra_args)
682    };
683
684    quote! {
685        |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
686            std::boxed::Box::pin(async move {
687                #(#extractions)*
688                bot.#method_name(ctx, #(#call_args),*).await
689            })
690        }
691    }
692}
693
694/// Argument extraction for non-command triggers (message/event/mention).
695///
696/// Preserves historical behaviour: each `String` parameter maps to the trigger
697/// capture group at its positional index, `User` becomes the sender, and any
698/// other type is filled with `Default::default()`.
699fn legacy_extractions(extra_args: &[(Ident, Type)]) -> Vec<TokenStream2> {
700    let mut out = Vec::new();
701    let mut str_idx = 0usize;
702    for (name, ty) in extra_args {
703        match classify(ty) {
704            TypeClass::User => out.push(quote! {
705                let #name = ctx.sender.clone().unwrap_or_default();
706            }),
707            TypeClass::StringTy => {
708                let idx = str_idx;
709                str_idx += 1;
710                out.push(quote! {
711                    let #name: String = if !ctx.captures.is_empty() {
712                        ctx.captures.get(#idx).cloned().unwrap_or_default()
713                    } else {
714                        ctx.message_text().to_string()
715                    };
716                });
717            }
718            _ => out.push(quote! {
719                let #name: #ty = std::default::Default::default();
720            }),
721        }
722    }
723    out
724}
725
726/// Argument extraction for command triggers: typed positional parsing of the
727/// command tail, replying with a generated usage string (and skipping the
728/// handler) when a required argument is missing or fails to parse.
729fn command_extractions(extra_args: &[(Ident, Type)], cmd: &str) -> Vec<TokenStream2> {
730    // The last argument sourced from the tail (everything except `User`); a
731    // trailing `String` here captures the rest of the line.
732    let last_tail_idx = extra_args.iter().rposition(|(_, ty)| !type_is(ty, "User"));
733
734    // Build the usage string from the signature.
735    let mut usage_parts: Vec<String> = Vec::new();
736    for (name, ty) in extra_args {
737        match classify(ty) {
738            TypeClass::User => {}
739            TypeClass::Opt { .. } => usage_parts.push(format!("[{name}]")),
740            TypeClass::VecTy { .. } => usage_parts.push(format!("[{name}...]")),
741            _ => usage_parts.push(format!("<{name}>")),
742        }
743    }
744    let usage = if usage_parts.is_empty() {
745        format!("usage: !{cmd}")
746    } else {
747        format!("usage: !{cmd} {}", usage_parts.join(" "))
748    };
749    let usage_fail = quote! {
750        { let _ = ctx.reply(#usage); return std::result::Result::Ok(()); }
751    };
752
753    // `next_token` needs `&mut __args`; the rest-consuming helpers take `self`.
754    // Only declare `__args` mutable when a token is actually pulled, to avoid an
755    // `unused_mut` warning under `-D warnings`.
756    let needs_mut = extra_args.iter().enumerate().any(|(i, (_, ty))| {
757        let is_last_tail = Some(i) == last_tail_idx;
758        match classify(ty) {
759            TypeClass::User => false,
760            TypeClass::StringTy => !is_last_tail,
761            TypeClass::Scalar(_) => true,
762            TypeClass::Opt { is_string, .. } => !is_string,
763            TypeClass::VecTy { .. } => false,
764        }
765    });
766    let has_tail_args = extra_args.iter().any(|(_, ty)| !type_is(ty, "User"));
767
768    let mut out: Vec<TokenStream2> = Vec::new();
769    if has_tail_args {
770        let binding = if needs_mut {
771            quote! { let mut __args = ircbot::internal::Args::new(&__tail); }
772        } else {
773            quote! { let __args = ircbot::internal::Args::new(&__tail); }
774        };
775        out.push(quote! {
776            let __tail: String = ctx.captures.first().cloned().unwrap_or_default();
777            #binding
778        });
779    }
780
781    for (i, (name, ty)) in extra_args.iter().enumerate() {
782        let is_last_tail = Some(i) == last_tail_idx;
783        match classify(ty) {
784            TypeClass::User => out.push(quote! {
785                let #name = ctx.sender.clone().unwrap_or_default();
786            }),
787            TypeClass::StringTy if is_last_tail => out.push(quote! {
788                let #name: String = __args.rest().to_string();
789            }),
790            TypeClass::StringTy => out.push(quote! {
791                let #name: String = match __args.next_token() {
792                    Some(t) => t.to_string(),
793                    None => #usage_fail,
794                };
795            }),
796            TypeClass::Scalar(scalar) => out.push(quote! {
797                let #name: #scalar = match __args.next_token() {
798                    Some(t) => match t.parse::<#scalar>() {
799                        Ok(v) => v,
800                        Err(_) => #usage_fail,
801                    },
802                    None => #usage_fail,
803                };
804            }),
805            TypeClass::Opt {
806                is_string: true, ..
807            } => out.push(quote! {
808                let #name: Option<String> = {
809                    let __r = __args.rest();
810                    if __r.is_empty() { None } else { Some(__r.to_string()) }
811                };
812            }),
813            TypeClass::Opt { inner, .. } => out.push(quote! {
814                let #name: Option<#inner> = match __args.next_token() {
815                    Some(t) => match t.parse::<#inner>() {
816                        Ok(v) => Some(v),
817                        Err(_) => #usage_fail,
818                    },
819                    None => None,
820                };
821            }),
822            TypeClass::VecTy {
823                is_string: true, ..
824            } => out.push(quote! {
825                let #name: Vec<String> = __args.rest_tokens();
826            }),
827            TypeClass::VecTy { inner, .. } => out.push(quote! {
828                let #name: Vec<#inner> = {
829                    let mut __out: Vec<#inner> = std::vec::Vec::new();
830                    for __t in __args.rest_tokens() {
831                        match __t.parse::<#inner>() {
832                            Ok(v) => __out.push(v),
833                            Err(_) => #usage_fail,
834                        }
835                    }
836                    __out
837                };
838            }),
839        }
840    }
841    out
842}
843
844// ─── #[command] / #[on] as standalone no-ops ─────────────────────────────────
845
846#[doc = include_str!("../docs/command.md")]
847#[proc_macro_attribute]
848pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
849    item
850}
851
852#[doc = include_str!("../docs/on.md")]
853#[proc_macro_attribute]
854pub fn on(_attr: TokenStream, item: TokenStream) -> TokenStream {
855    item
856}