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}