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}