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")]` or `#[command("name", target = "...")]`
46struct CommandArgs {
47    name: String,
48    target: Option<String>,
49}
50
51impl syn::parse::Parse for CommandArgs {
52    fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
53        let name: syn::LitStr = input.parse()?;
54        let mut target = None;
55        while input.peek(syn::Token![,]) {
56            let _: syn::Token![,] = input.parse()?;
57            if input.is_empty() {
58                break;
59            }
60            let key: Ident = input.parse()?;
61            let _: syn::Token![=] = input.parse()?;
62            let val: syn::LitStr = input.parse()?;
63            if key == "target" {
64                target = Some(val.value());
65            }
66        }
67        Ok(CommandArgs {
68            name: name.value(),
69            target,
70        })
71    }
72}
73
74// ─── #[bot] ──────────────────────────────────────────────────────────────────
75
76/// Derive-like attribute that turns an `impl` block into a runnable IRC bot.
77///
78/// # Custom state
79///
80/// Pass `state = SomeType` to give the bot a public `state` field your handlers
81/// can read:
82///
83/// ```ignore
84/// #[derive(Default)]
85/// struct Counter { hits: std::sync::atomic::AtomicUsize }
86///
87/// #[bot(state = Counter)]
88/// impl MyBot {
89///     #[command("ping")]
90///     async fn ping(&self, ctx: ircbot::Context) -> ircbot::Result {
91///         let n = self.state.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
92///         ctx.reply(format!("pong #{n}"))
93///     }
94/// }
95/// ```
96///
97/// The state type must implement [`Default`] (it is initialised with
98/// `Default::default()` by both `MyBot::default()` and `MyBot::new`) and must be
99/// `Send + Sync + 'static` (the bot is shared across tasks as an `Arc`; that
100/// bound is checked at `main_loop`). Because handlers receive `&self`, mutating
101/// state requires interior mutability — an `AtomicUsize`, a `Mutex<…>`, etc. To
102/// start from a non-default value, assign the public field after constructing:
103/// `let mut bot = MyBot::new(…).await?; bot.state = …;`.
104///
105/// Note: a `SIGHUP` hot-reload re-execs the binary, so in-memory `state` is
106/// reconstructed via `Default` and is **not** carried across the reload.
107///
108/// This is sugar over the lower-level API: a bot is any
109/// `Arc<T: Send + Sync + 'static>` passed to `ircbot::internal::run_bot` with a
110/// hand-built `Vec<ircbot::HandlerEntry<T>>`, which you can use directly when you
111/// want full control over the bot type.
112///
113/// # Panics
114///
115/// Panics at compile time if the annotated `impl` block does not use a simple
116/// (non-generic, non-path) type name, e.g. `impl MyBot { … }`.
117#[allow(clippy::too_many_lines)]
118#[proc_macro_attribute]
119pub fn bot(attr: TokenStream, item: TokenStream) -> TokenStream {
120    let args = parse_macro_input!(attr as BotArgs);
121    let input = parse_macro_input!(item as ItemImpl);
122
123    let self_ty = &input.self_ty;
124    let struct_name = match self_ty.as_ref() {
125        Type::Path(tp) => tp
126            .path
127            .get_ident()
128            .cloned()
129            .expect("#[bot] expects a simple struct name"),
130        _ => panic!("#[bot] expects a simple struct name"),
131    };
132
133    let mut handler_entries: Vec<TokenStream2> = Vec::new();
134    let mut cleaned_methods: Vec<TokenStream2> = Vec::new();
135
136    for item in &input.items {
137        if let ImplItem::Fn(method) = item {
138            let method_name = &method.sig.ident;
139
140            // Extra args beyond &self and ctx
141            let extra_args: Vec<(String, String)> = method
142                .sig
143                .inputs
144                .iter()
145                .skip(2)
146                .filter_map(|arg| {
147                    if let FnArg::Typed(pt) = arg {
148                        let name = match pt.pat.as_ref() {
149                            Pat::Ident(pi) => pi.ident.to_string(),
150                            _ => "arg".to_string(),
151                        };
152                        let ty = match pt.ty.as_ref() {
153                            Type::Path(tp) => tp
154                                .path
155                                .segments
156                                .last()
157                                .map(|s| s.ident.to_string())
158                                .unwrap_or_default(),
159                            _ => "Unknown".to_string(),
160                        };
161                        Some((name, ty))
162                    } else {
163                        None
164                    }
165                })
166                .collect();
167
168            let mut trigger_tokens: Option<TokenStream2> = None;
169            let mut cleaned_attrs: Vec<syn::Attribute> = Vec::new();
170
171            for attr in &method.attrs {
172                let Some(ident) = attr.path().get_ident() else {
173                    cleaned_attrs.push(attr.clone());
174                    continue;
175                };
176
177                match ident.to_string().as_str() {
178                    "command" => {
179                        if let Meta::List(ml) = &attr.meta {
180                            let args: CommandArgs =
181                                syn::parse2(ml.tokens.clone()).unwrap_or(CommandArgs {
182                                    name: String::new(),
183                                    target: None,
184                                });
185                            let name = &args.name;
186                            let target_ts = opt_str_ts(args.target.as_deref());
187                            trigger_tokens = Some(quote! {
188                                ircbot::Trigger::Command {
189                                    name: #name.to_string(),
190                                    target: #target_ts,
191                                }
192                            });
193                        }
194                    }
195                    "on" => {
196                        if let Meta::List(ml) = &attr.meta {
197                            let metas_result = ml.parse_args_with(
198                                syn::punctuated::Punctuated::<Meta, syn::Token![,]>::parse_terminated,
199                            );
200
201                            let mut event: Option<String> = None;
202                            let mut message: Option<String> = None;
203                            let mut command_on: Option<String> = None;
204                            let mut target: Option<String> = None;
205                            let mut regex: Option<String> = None;
206                            let mut mention = false;
207                            let mut cron_interval: Option<String> = None;
208                            let mut cron_tz: Option<String> = None;
209
210                            if let Ok(metas) = metas_result {
211                                for meta in metas {
212                                    match &meta {
213                                        Meta::Path(p) if p.is_ident("mention") => {
214                                            mention = true;
215                                        }
216                                        Meta::NameValue(nv) => {
217                                            let k = nv
218                                                .path
219                                                .get_ident()
220                                                .map(ToString::to_string)
221                                                .unwrap_or_default();
222                                            if let Expr::Lit(ExprLit {
223                                                lit: Lit::Str(s), ..
224                                            }) = &nv.value
225                                            {
226                                                let v = s.value();
227                                                match k.as_str() {
228                                                    "event" => event = Some(v),
229                                                    "message" => message = Some(v),
230                                                    "command" => command_on = Some(v),
231                                                    "target" => target = Some(v),
232                                                    "regex" => regex = Some(v),
233                                                    "cron" => cron_interval = Some(v),
234                                                    "tz" => cron_tz = Some(v),
235                                                    _ => {}
236                                                }
237                                            }
238                                        }
239                                        _ => {}
240                                    }
241                                }
242                            }
243
244                            let target_ts = opt_str_ts(target.as_deref());
245                            // Precedence: message > command > event > mention > cron.
246                            // Only the first matching key wins; combining multiple
247                            // trigger types in one `#[on(...)]` is not supported.
248                            if let Some(msg_pat) = message {
249                                trigger_tokens = Some(quote! {
250                                    ircbot::Trigger::Message {
251                                        pattern: #msg_pat.to_string(),
252                                        target: #target_ts,
253                                    }
254                                });
255                            } else if let Some(cmd) = command_on {
256                                trigger_tokens = Some(quote! {
257                                    ircbot::Trigger::Command {
258                                        name: #cmd.to_string(),
259                                        target: #target_ts,
260                                    }
261                                });
262                            } else if let Some(ev) = event {
263                                let regex_ts = opt_str_ts(regex.as_deref());
264                                trigger_tokens = Some(quote! {
265                                    ircbot::Trigger::Event {
266                                        event: #ev.to_string(),
267                                        target: #target_ts,
268                                        regex: #regex_ts,
269                                    }
270                                });
271                            } else if mention {
272                                trigger_tokens = Some(quote! {
273                                    ircbot::Trigger::Mention {
274                                        target: #target_ts,
275                                    }
276                                });
277                            } else if let Some(cron_str) = cron_interval {
278                                // Validate the cron expression at compile time.
279                                if let Err(e) = cron_str.parse::<cron::Schedule>() {
280                                    panic!(
281                                        "invalid cron expression {cron_str:?}: {e}\n\
282                                         \n\
283                                         The expression must use the 6-field Quartz format \
284                                         with an optional 7th year field:\n\
285                                         \n\
286                                         sec  min  hour  day-of-month  month  day-of-week  [year]\n\
287                                         \n\
288                                         Examples:\n\
289                                         \"0 0 * * * *\"          every hour (on the minute)\n\
290                                         \"0 0 8-16 * * MON-FRI\" top of each hour, 8 a.m.–4 p.m., weekdays\n\
291                                         \"0 */15 * * * *\"        every 15 minutes\n\
292                                         \"0 0 9 * * MON\"         every Monday at 9 a.m."
293                                    );
294                                }
295                                // Validate the timezone at compile time (defaults to UTC).
296                                let tz_str = cron_tz.as_deref().unwrap_or("UTC");
297                                if let Err(e) = tz_str.parse::<chrono_tz::Tz>() {
298                                    panic!(
299                                        "invalid timezone {tz_str:?}: {e}\n\
300                                         \n\
301                                         Use an IANA timezone name such as:\n\
302                                         \"UTC\", \"America/New_York\", \"Europe/London\", \
303                                         \"Asia/Tokyo\""
304                                    );
305                                }
306                                let tz_str = tz_str.to_string();
307                                trigger_tokens = Some(quote! {
308                                    ircbot::Trigger::Cron {
309                                        schedule: #cron_str.to_string(),
310                                        tz: #tz_str.to_string(),
311                                        target: #target_ts,
312                                    }
313                                });
314                            }
315                        }
316                    }
317                    _ => {
318                        cleaned_attrs.push(attr.clone());
319                    }
320                }
321            }
322
323            if let Some(trigger) = trigger_tokens {
324                let wrapper = build_wrapper(method_name, &extra_args);
325                handler_entries.push(quote! {
326                    ircbot::HandlerEntry {
327                        trigger: #trigger,
328                        handler: std::boxed::Box::new(#wrapper),
329                    }
330                });
331
332                let mut cleaned = method.clone();
333                cleaned.attrs = cleaned_attrs;
334                cleaned_methods.push(quote! { #cleaned });
335            } else {
336                cleaned_methods.push(quote! { #method });
337            }
338        } else {
339            let it = item;
340            cleaned_methods.push(quote! { #it });
341        }
342    }
343
344    // Optional user state field. When `state = Type` is absent both fragments are
345    // empty, so the generated tokens are identical to the no-state case. The init
346    // fragment carries a leading comma because the `__state` field in the struct
347    // literals below has no trailing comma.
348    let state_field_decl = match &args.state {
349        Some(ty) => quote! { pub state: #ty, },
350        None => quote! {},
351    };
352    let state_field_init = match &args.state {
353        Some(_) => quote! { , state: std::default::Default::default() },
354        None => quote! {},
355    };
356    // A constructor that takes a pre-built state and attaches no live
357    // connection. Only meaningful when the bot has a `state` field, so it is
358    // emitted solely in the `state = Type` case. This is the supported entry
359    // point for unit-testing handlers (see `ircbot::testing`): it bypasses the
360    // `Default` impl, which would build state via `Default::default()` — wrong
361    // for any state that opens files, sockets, or other real resources.
362    let from_state_method = match &args.state {
363        Some(ty) => quote! {
364            /// Construct the bot from a pre-built `state`, with no live IRC
365            /// connection attached.
366            ///
367            /// This is the intended way to unit-test handlers. Handlers take
368            /// `&self` and reach the connection only when they send a reply,
369            /// which in tests is captured by a
370            /// [`TestContext`](ircbot::testing::TestContext) instead — so a bot
371            /// built this way can drive handlers directly without ever touching
372            /// the network.
373            ///
374            /// Prefer this over [`Default::default`] whenever your state type's
375            /// `Default` does real work (opening a database, reading config,
376            /// connecting to a service): `from_state` lets the test build a
377            /// purpose-made state — an in-memory store, a temp-dir fixture —
378            /// and inject it directly.
379            ///
380            /// # Example
381            ///
382            /// ```rust,no_run
383            /// # use ircbot::{bot, Context, Result};
384            /// # use ircbot::testing::TestContext;
385            /// #[derive(Default)]
386            /// struct State { greeting: String }
387            ///
388            /// #[bot(state = State)]
389            /// impl Greeter {
390            ///     #[on(mention)]
391            ///     async fn hello(&self, ctx: Context, _text: String) -> Result {
392            ///         ctx.reply(self.state.greeting.clone())
393            ///     }
394            /// }
395            ///
396            /// #[tokio::test]
397            /// async fn replies_with_configured_greeting() {
398            ///     let bot = Greeter::from_state(State { greeting: "hi!".into() });
399            ///     let mut tc = TestContext::channel("#test", "alice", "greeter: yo");
400            ///     bot.hello(tc.take_ctx(), "yo".into()).await.unwrap();
401            ///     // `reply` prefixes the sender's nick in a channel.
402            ///     assert_eq!(tc.next_reply().as_deref(), Some("PRIVMSG #test :alice, hi!\r\n"));
403            /// }
404            /// ```
405            pub fn from_state(state: #ty) -> Self {
406                #struct_name { __state: std::option::Option::None, state }
407            }
408        },
409        None => quote! {},
410    };
411
412    quote! {
413        pub struct #struct_name {
414            __state: std::option::Option<ircbot::State>,
415            #state_field_decl
416        }
417
418        impl Default for #struct_name {
419            fn default() -> Self {
420                #struct_name { __state: std::option::Option::None #state_field_init }
421            }
422        }
423
424        impl #struct_name {
425            /// Connect to an IRC server and return a bot ready to run.
426            ///
427            /// On Unix, if this process was started by `exec_reload` the live
428            /// TCP connection is inherited from the parent binary and no new
429            /// connection is made.  The `nick`, `server`, and `channels`
430            /// arguments are used only when no inherited connection is present.
431            pub async fn new(
432                nick: impl Into<String>,
433                server: impl AsRef<str>,
434                channels: impl IntoIterator<Item = impl Into<String>>,
435            ) -> std::result::Result<Self, Box<dyn std::error::Error + Send + Sync>> {
436                // On Unix, check for an inherited fd from a hot-reload exec.
437                #[cfg(unix)]
438                if let Some(state) = ircbot::State::try_inherit_from_env()? {
439                    eprintln!("[ircbot] hot-reload: resumed on inherited connection");
440                    return Ok(#struct_name { __state: Some(state) #state_field_init });
441                }
442
443                let state = ircbot::State::connect(
444                    nick.into(),
445                    server.as_ref(),
446                    channels.into_iter().map(|c| ircbot::Channel::from(c.into())).collect(),
447                ).await?;
448                Ok(#struct_name { __state: Some(state) #state_field_init })
449            }
450
451            #from_state_method
452
453            /// Set a custom CTCP `VERSION` reply.
454            ///
455            /// By default the bot answers CTCP `VERSION` with
456            /// `ircbot <crate-version>`. Call this (before `main_loop`) to reply
457            /// with your own identifier instead. The value is re-applied on a
458            /// `SIGHUP` hot-reload, since the builder runs again on startup.
459            #[must_use]
460            pub fn with_ctcp_version(mut self, version: impl Into<String>) -> Self {
461                if let Some(state) = self.__state.take() {
462                    self.__state = Some(state.with_ctcp_version(version));
463                }
464                self
465            }
466
467            /// Run the bot's main event loop.
468            ///
469            /// On Unix, listens for `SIGHUP`.  When received, the current
470            /// process execs the bot binary at the same path, passing the live
471            /// TCP socket fd to the new process so the IRC connection is never
472            /// interrupted.  If the exec fails the bot continues running.
473            pub async fn main_loop(mut self) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
474                let state = self.__state.take().expect("bot already started");
475
476                #[cfg(unix)]
477                let (raw_fd, reload_nick, reload_server, reload_channels,
478                     reload_ka_interval_ms, reload_ka_timeout_ms) = (
479                    state.raw_fd,
480                    state.nick.as_str().to_string(),
481                    state.server.clone(),
482                    state.channels.iter().map(|c| c.as_str().to_string()).collect::<std::vec::Vec<String>>(),
483                    state.keepalive_interval().as_millis() as u64,
484                    state.keepalive_timeout().as_millis() as u64,
485                );
486
487                let bot_arc = std::sync::Arc::new(self);
488
489                // Install a SIGHUP listener that execs the new binary with the
490                // live fd inherited — zero-disconnect binary hot-reload.
491                #[cfg(unix)]
492                {
493                    tokio::spawn(async move {
494                        use tokio::signal::unix::{signal, SignalKind};
495                        match signal(SignalKind::hangup()) {
496                            Ok(mut stream) => {
497                                while stream.recv().await.is_some() {
498                                    eprintln!("[ircbot] SIGHUP — hot-reload: exec new binary");
499                                    let err = ircbot::hot_reload::exec_reload(
500                                        raw_fd,
501                                        &reload_nick,
502                                        &reload_server,
503                                        &reload_channels,
504                                        reload_ka_interval_ms,
505                                        reload_ka_timeout_ms,
506                                    );
507                                    // exec_reload only returns on failure.
508                                    eprintln!("[ircbot] hot-reload exec failed: {err}");
509                                }
510                            }
511                            Err(e) => {
512                                eprintln!("[ircbot] failed to install SIGHUP handler: {e}");
513                            }
514                        }
515                    });
516                }
517
518                ircbot::internal::run_bot(bot_arc, state, #struct_name::__handlers()).await
519            }
520
521            fn __handlers() -> Vec<ircbot::HandlerEntry<#struct_name>> {
522                vec![ #(#handler_entries),* ]
523            }
524
525            #(#cleaned_methods)*
526        }
527    }
528    .into()
529}
530
531// ─── helpers ─────────────────────────────────────────────────────────────────
532
533fn opt_str_ts(s: Option<&str>) -> TokenStream2 {
534    if let Some(v) = s {
535        quote! { Some(#v.to_string()) }
536    } else {
537        quote! { None }
538    }
539}
540
541fn build_wrapper(method_name: &Ident, extra_args: &[(String, String)]) -> TokenStream2 {
542    if extra_args.is_empty() {
543        return quote! {
544            |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
545                std::boxed::Box::pin(async move { bot.#method_name(ctx).await })
546            }
547        };
548    }
549
550    let mut extractions: Vec<TokenStream2> = Vec::new();
551    let mut call_args: Vec<TokenStream2> = Vec::new();
552    let mut str_idx = 0usize;
553
554    for (name, ty) in extra_args {
555        let ident = Ident::new(name, Span::call_site());
556        call_args.push(quote! { #ident });
557        match ty.as_str() {
558            "User" => {
559                extractions.push(quote! {
560                    let #ident = ctx.sender.clone().unwrap_or_default();
561                });
562            }
563            "String" => {
564                let idx = str_idx;
565                str_idx += 1;
566                extractions.push(quote! {
567                    let #ident: String = if !ctx.captures.is_empty() {
568                        ctx.captures.get(#idx).cloned().unwrap_or_default()
569                    } else {
570                        ctx.message_text().to_string()
571                    };
572                });
573            }
574            _ => {
575                let ty_ident = Ident::new(ty, Span::call_site());
576                extractions.push(quote! {
577                    let #ident: #ty_ident = Default::default();
578                });
579            }
580        }
581    }
582
583    quote! {
584        |bot: std::sync::Arc<_>, ctx: ircbot::Context| -> ircbot::BoxFuture<ircbot::Result> {
585            std::boxed::Box::pin(async move {
586                #(#extractions)*
587                bot.#method_name(ctx, #(#call_args),*).await
588            })
589        }
590    }
591}
592
593// ─── #[command] / #[on] as standalone no-ops ─────────────────────────────────
594
595#[doc = include_str!("../docs/command.md")]
596#[proc_macro_attribute]
597pub fn command(_attr: TokenStream, item: TokenStream) -> TokenStream {
598    item
599}
600
601#[doc = include_str!("../docs/on.md")]
602#[proc_macro_attribute]
603pub fn on(_attr: TokenStream, item: TokenStream) -> TokenStream {
604    item
605}