aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md40
-rw-r--r--mingling/src/lib.rs6
-rw-r--r--mingling_macros/src/attr.rs2
-rw-r--r--mingling_macros/src/attr/command.rs340
-rw-r--r--mingling_macros/src/lib.rs87
-rw-r--r--mingling_pathf/src/pattern_analyzer.rs1
-rw-r--r--mingling_pathf/src/patterns.rs2
-rw-r--r--mingling_pathf/src/patterns/command.rs81
8 files changed, 559 insertions, 0 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15a5f7b..b53d3d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -93,6 +93,46 @@ None
These methods provide ergonomic alternatives to `to_result()` + `unwrap()` / `unwrap_or_default()` / `unwrap_or_else()` / `expect()` chaining, reducing boilerplate when working with `PickArgParsed` tuples directly.
+3. **[`macros:command`]** Added the `#[command]` attribute macro (feature-gated behind `extra_macros`) that converts a plain function with a `Vec<String>` parameter into a fully wired Mingling command. The macro:
+
+ - Calls `dispatcher!("command_name")` to register the dispatcher entry.
+ - Generates a `#[chain]` wrapper that bridges the entry type (`Entry{Pascal}`) to the original function.
+ - Preserves the original function unchanged (including attributes, extensions, visibility, and asyncness).
+
+ **Syntax variants:**
+
+ ```rust,ignore
+ // Simple form — auto-derives names from function name
+ #[command]
+ fn greet(args: Vec<String>) -> Next { /* ... */ }
+ // → dispatcher!("greet"), CMDGreet, EntryGreet
+
+ // Explicit node path
+ #[command(node = "hello.world")]
+ fn greet(args: Vec<String>) -> Next { /* ... */ }
+ // → dispatcher!("hello.world", CMDGreet => EntryGreet)
+
+ // Explicit name/entry overrides
+ #[command(name = MyDispatcher, entry = MyEntry)]
+ fn greet(args: Vec<String>) -> Next { /* ... */ }
+ // → dispatcher!("greet", MyDispatcher => MyEntry)
+ ```
+
+ **Extension attributes** (e.g. `buffer`, `routeify`) passed as bare paths in `#[command(...)]` are applied as `#[ext]` attributes **on the original function**, not on the generated chain wrapper. The chain wrapper always uses bare `#[::mingling::macros::chain]`.
+
+ **Resource injection:** Parameters after the first are treated as resource injections and passed through to the generated `#[chain]` wrapper unchanged.
+
+ **Hidden module:** Each `#[command]` generates a `#[doc(hidden)]` module `__command_{fn_name}_module` that re-exports all generated types (`CMD*`, `Entry*`, chain struct, dispatcher static) for pathf / external access.
+
+ Internally, the implementation:
+ - Parses `#[command(...)]` arguments via `CommandArgs` supporting `node`, `name`, `entry` keys and extension paths.
+ - Validates function constraints (no `self`, at least one parameter).
+ - Handles async functions (rejected without the `async` feature).
+ - Resolves default names via `just_fmt::dot_case!` / `just_fmt::pascal_case!`.
+ - Builds a chain wrapper that calls the original function with `entry.into()` for the first argument.
+
+4. **[`pathf:patterns`]** Added `CommandPattern` to the `pathf` pattern analyzer, matching functions annotated with `#[command]`. The pattern tracks the generated hidden module (`__command_{fn}_module`) and marks it as a local module item via `AnalyzeItem::local_module()`. The build system generates a glob re-export `use path::__command_{fn}_module::*;` to bring all generated types (`Entry*`, `CMD*`, chain struct, dispatcher static) into scope.
+
#### **BREAKING CHANGES** (API CHANGES):
None
diff --git a/mingling/src/lib.rs b/mingling/src/lib.rs
index 19f2188..d7c8e8f 100644
--- a/mingling/src/lib.rs
+++ b/mingling/src/lib.rs
@@ -52,6 +52,9 @@ pub mod macros {
pub use mingling_macros::buffer;
/// `#[chain]` - Used to generate a struct implementing the `Chain` trait via a method
pub use mingling_macros::chain;
+ /// `#[command]` - Declares a command from a plain function with `Vec<String>` entry
+ #[cfg(feature = "extra_macros")]
+ pub use mingling_macros::command;
/// `#[completion(EntryType)]` - Used to generate completion entry
#[cfg(feature = "comp")]
pub use mingling_macros::completion;
@@ -223,6 +226,9 @@ pub mod prelude {
/// Re-export of the `chain` macro for defining a chain of commands.
#[cfg(feature = "macros")]
pub use crate::macros::chain;
+ /// Re-export of the `#[command]` macro for declaring commands from plain functions.
+ #[cfg(all(feature = "extra_macros", feature = "macros"))]
+ pub use crate::macros::command;
/// Re-export of the `dispatcher` macro for routing commands.
#[cfg(feature = "macros")]
pub use crate::macros::dispatcher;
diff --git a/mingling_macros/src/attr.rs b/mingling_macros/src/attr.rs
index ed7f58b..8c17878 100644
--- a/mingling_macros/src/attr.rs
+++ b/mingling_macros/src/attr.rs
@@ -1,4 +1,6 @@
pub(crate) mod chain;
+#[cfg(feature = "extra_macros")]
+pub(crate) mod command;
#[cfg(feature = "comp")]
pub(crate) mod completion;
#[cfg(feature = "clap")]
diff --git a/mingling_macros/src/attr/command.rs b/mingling_macros/src/attr/command.rs
new file mode 100644
index 0000000..2e05cc9
--- /dev/null
+++ b/mingling_macros/src/attr/command.rs
@@ -0,0 +1,340 @@
+use proc_macro::TokenStream;
+use proc_macro2::TokenStream as TokenStream2;
+use quote::quote;
+use syn::parse::{Parse, ParseStream};
+use syn::parse_macro_input;
+use syn::spanned::Spanned;
+use syn::token::Comma;
+use syn::{FnArg, Ident, ItemFn, LitStr, PatType, Token, Type};
+
+/// Parsed arguments for `#[command(...)]`.
+///
+/// Supports:
+/// - `node = "dot.separated.path"` — explicit command path
+/// - `name = CMDName` — explicit CMD struct name
+/// - `entry = EntryName` — explicit Entry struct name
+/// - bare paths like `routeify`, `::mingling::macros::routeify` — extension attrs for the original fn
+struct CommandArgs {
+ node: Option<LitStr>,
+ name: Option<Ident>,
+ entry: Option<Ident>,
+ exts: Vec<syn::Path>,
+}
+
+impl Parse for CommandArgs {
+ fn parse(input: ParseStream) -> syn::Result<Self> {
+ let mut node = None;
+ let mut name = None;
+ let mut entry = None;
+ let mut exts = Vec::new();
+
+ while !input.is_empty() {
+ if input.peek(Ident) && input.peek2(Token![=]) {
+ // key = value pair
+ let key: Ident = input.parse()?;
+ input.parse::<Token![=]>()?;
+
+ if key == "node" {
+ if node.is_some() {
+ return Err(input.error("duplicate `node` argument"));
+ }
+ node = Some(input.parse()?);
+ } else if key == "name" {
+ if name.is_some() {
+ return Err(input.error("duplicate `name` argument"));
+ }
+ name = Some(input.parse()?);
+ } else if key == "entry" {
+ if entry.is_some() {
+ return Err(input.error("duplicate `entry` argument"));
+ }
+ entry = Some(input.parse()?);
+ } else {
+ return Err(input.error(format!(
+ "unknown key `{}`; expected `node`, `name`, or `entry`",
+ key
+ )));
+ }
+ } else {
+ // Extension path (e.g. `routeify` or `::mingling::macros::routeify`)
+ let ext: syn::Path = input.parse()?;
+ exts.push(ext);
+ }
+
+ // Skip optional trailing comma
+ if input.peek(Comma) {
+ let _ = input.parse::<Comma>();
+ }
+ }
+
+ Ok(CommandArgs {
+ node,
+ name,
+ entry,
+ exts,
+ })
+ }
+}
+
+/// Returns the default node path as a dot-separated string, derived from the function name.
+///
+/// Example: `greet_someone` -> `"greet.someone"`, `greet` -> `"greet"`
+fn default_node_from_fn(fn_name: &Ident) -> String {
+ just_fmt::dot_case!(fn_name.to_string())
+}
+
+/// Checks basic function constraints for `#[command]`.
+/// Returns `Err(compile_error token stream)` on failure.
+fn validate_function(f: &ItemFn) -> Result<(), TokenStream2> {
+ if f.sig
+ .inputs
+ .iter()
+ .any(|arg| matches!(arg, FnArg::Receiver(_)))
+ {
+ return Err(syn::Error::new(
+ f.sig.span(),
+ "#[command] function cannot have a `self` parameter",
+ )
+ .to_compile_error());
+ }
+
+ if f.sig.inputs.is_empty() {
+ return Err(syn::Error::new(
+ f.sig.span(),
+ "#[command] function must have at least one parameter (e.g., `args: Vec<String>`)",
+ )
+ .to_compile_error());
+ }
+
+ Ok(())
+}
+
+/// Returns `(wrapper_async_token, await_call)` for the chain wrapper.
+///
+/// When the original function is async and the `async` feature is enabled,
+/// the wrapper needs to be `async` and call `.await` on the original function.
+/// Without the feature, async functions are rejected.
+fn handle_async(f: &ItemFn) -> Result<(TokenStream2, TokenStream2), TokenStream2> {
+ let is_async = f.sig.asyncness.is_some();
+
+ #[cfg(not(feature = "async"))]
+ if is_async {
+ return Err(syn::Error::new(
+ f.sig.span(),
+ "#[command] function cannot be async when the `async` feature is disabled",
+ )
+ .to_compile_error());
+ }
+
+ let wrapper_async = is_async.then_some(quote! { async }).unwrap_or_default();
+ let await_call = is_async.then_some(quote! { .await }).unwrap_or_default();
+ Ok((wrapper_async, await_call))
+}
+
+/// All resolved identifiers derived from `#[command]` arguments + function name.
+struct ResolvedNames {
+ /// `node_str` as a string literal token
+ node_lit: LitStr,
+ /// Whether the user supplied any explicit override (node/name/entry)
+ has_overrides: bool,
+ /// CMD struct name (e.g. `CMDGreet`)
+ cmd_name: Ident,
+ /// Entry struct name (e.g. `EntryGreet`)
+ entry_type: Ident,
+ /// Chain wrapper function name (e.g. `__command_chain_greet`)
+ chain_fn_name: Ident,
+}
+
+/// Resolves `node`, `cmd_name`, `entry_type`, and `chain_fn_name` from
+/// the attribute args and the original function name.
+fn resolve_names(fn_name: &Ident, args: &CommandArgs) -> ResolvedNames {
+ let fn_name_str = fn_name.to_string();
+
+ let node_str = match &args.node {
+ Some(lit) => lit.value(),
+ None => default_node_from_fn(fn_name),
+ };
+ let node_lit = syn::LitStr::new(&node_str, fn_name.span());
+
+ let has_overrides = args.node.is_some() || args.name.is_some() || args.entry.is_some();
+
+ let cmd_name = args.name.clone().unwrap_or_else(|| {
+ let pascal = just_fmt::pascal_case!(&node_str);
+ Ident::new(&format!("CMD{pascal}"), fn_name.span())
+ });
+
+ let entry_type = args.entry.clone().unwrap_or_else(|| {
+ let pascal = just_fmt::pascal_case!(&node_str);
+ Ident::new(&format!("Entry{pascal}"), fn_name.span())
+ });
+
+ let chain_fn_name = Ident::new(&format!("__command_chain_{}", fn_name_str), fn_name.span());
+
+ ResolvedNames {
+ node_lit,
+ has_overrides,
+ cmd_name,
+ entry_type,
+ chain_fn_name,
+ }
+}
+
+/// Converts extension paths into `#[ext]` attribute token streams.
+fn build_ext_attrs(exts: &[syn::Path]) -> Vec<TokenStream2> {
+ exts.iter().map(|ext| quote! { #[#ext] }).collect()
+}
+
+/// Builds the wrapper function's parameter list by replacing the first
+/// parameter's type with the entry type (e.g. `Vec<String>` → `EntryGreet`).
+fn build_wrapper_params(
+ sig: &syn::Signature,
+ entry_type: &Ident,
+) -> syn::punctuated::Punctuated<FnArg, syn::token::Comma> {
+ let mut params = sig.inputs.clone();
+ if let Some(FnArg::Typed(first)) = params.first_mut() {
+ first.ty = Box::new(Type::Path(syn::TypePath {
+ qself: None,
+ path: syn::Path::from(entry_type.clone()),
+ }));
+ }
+ params
+}
+
+/// Builds call arguments for the original function call inside the wrapper.
+///
+/// - The first argument gets `.into()` (converts `EntryGreet` → `Vec<String>`).
+/// - Resource injection params (2nd onward) pass through as-is.
+fn build_call_args(sig: &syn::Signature) -> Vec<TokenStream2> {
+ sig.inputs
+ .iter()
+ .enumerate()
+ .map(|(i, arg)| {
+ let pat = match arg {
+ FnArg::Typed(PatType { pat, .. }) => quote! { #pat },
+ FnArg::Receiver(_) => unreachable!(),
+ };
+ if i == 0 {
+ quote! { #pat.into() }
+ } else {
+ pat
+ }
+ })
+ .collect()
+}
+
+/// Generates the `dispatcher!(...)` call.
+///
+/// - No overrides → abbreviated form: `dispatcher!("node")`
+/// - Any override → explicit form: `dispatcher!("node", CMDName => EntryName)`
+fn build_dispatcher_invoke(names: &ResolvedNames) -> TokenStream2 {
+ let node_lit = &names.node_lit;
+ if names.has_overrides {
+ let cmd_name = &names.cmd_name;
+ let entry_type = &names.entry_type;
+ quote! { ::mingling::macros::dispatcher!(#node_lit, #cmd_name => #entry_type); }
+ } else {
+ quote! { ::mingling::macros::dispatcher!(#node_lit); }
+ }
+}
+
+pub(crate) fn command_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
+ let input_fn = parse_macro_input!(item as ItemFn);
+
+ // validation
+ if let Err(err) = validate_function(&input_fn) {
+ return err.into();
+ }
+
+ // parse attribute
+ let args: CommandArgs = if attr.is_empty() {
+ CommandArgs {
+ node: None,
+ name: None,
+ entry: None,
+ exts: Vec::new(),
+ }
+ } else {
+ parse_macro_input!(attr as CommandArgs)
+ };
+
+ // async handling
+ let (wrapper_async, await_call) = match handle_async(&input_fn) {
+ Ok(pair) => pair,
+ Err(err) => return err.into(),
+ };
+
+ // resolve node / name / entry
+ let fn_name = &input_fn.sig.ident;
+ let names = resolve_names(fn_name, &args);
+ let chain_fn_name = &names.chain_fn_name;
+
+ // build extension attributes (applied to the ORIGINAL function)
+ let ext_attrs = build_ext_attrs(&args.exts);
+
+ // build wrapper
+ let wrapper_params = build_wrapper_params(&input_fn.sig, &names.entry_type);
+ let call_args = build_call_args(&input_fn.sig);
+
+ // build dispatcher invocation
+ let dispatcher_invoke = build_dispatcher_invoke(&names);
+
+ // preserve original function
+ let mut fn_attrs = input_fn.attrs.clone();
+ fn_attrs.retain(|attr| !attr.path().is_ident("command"));
+
+ let vis = &input_fn.vis;
+ let asyncness = input_fn.sig.asyncness;
+ let generics = &input_fn.sig.generics;
+ let orig_params = &input_fn.sig.inputs;
+ let orig_return = &input_fn.sig.output;
+ let fn_body = &input_fn.block;
+
+ // compute names for the re‑export module and internal structs
+ let fn_name_s = fn_name.to_string();
+ let mod_name = Ident::new(&format!("__command_{}_module", &fn_name_s), fn_name.span());
+ let wrapper_full = format!("__command_chain_{}", &fn_name_s);
+ let snaked_wrapper = just_fmt::snake_case!(wrapper_full);
+ let chain_internal = Ident::new(
+ &format!("__internal_chain_{}", snaked_wrapper),
+ fn_name.span(),
+ );
+
+ // dispatcher internal static (only exists with dispatch_tree feature)
+ #[cfg(feature = "dispatch_tree")]
+ let snaked_node = just_fmt::snake_case!(names.node_lit.value());
+ #[cfg(feature = "dispatch_tree")]
+ let dispatcher_internal = Ident::new(
+ &format!("__internal_dispatcher_{}", snaked_node),
+ fn_name.span(),
+ );
+ #[cfg(not(feature = "dispatch_tree"))]
+ let dispatcher_internal = quote! {};
+
+ let cmd_name = &names.cmd_name;
+ let entry_type = &names.entry_type;
+
+ // assemble output
+ let expanded = quote! {
+ #dispatcher_invoke
+
+ #[::mingling::macros::chain]
+ #vis #wrapper_async fn #chain_fn_name #generics(#wrapper_params) -> crate::Next {
+ #fn_name(#(#call_args),*)#await_call.into()
+ }
+
+ #(#fn_attrs)*
+ #(#ext_attrs)*
+ #vis #asyncness fn #fn_name #generics(#orig_params) #orig_return #fn_body
+
+ // hidden module gathering all generated types for pathf / external access
+ #[doc(hidden)]
+ #vis mod #mod_name {
+ #vis use super::#cmd_name;
+ #vis use super::#entry_type;
+ #vis use super::#chain_internal;
+ #vis use super::#dispatcher_internal;
+ }
+ };
+
+ expanded.into()
+}
diff --git a/mingling_macros/src/lib.rs b/mingling_macros/src/lib.rs
index 27563a4..ae874af 100644
--- a/mingling_macros/src/lib.rs
+++ b/mingling_macros/src/lib.rs
@@ -1100,6 +1100,93 @@ pub fn program_setup(attr: TokenStream, item: TokenStream) -> TokenStream {
program_setup::setup_attr(attr, item)
}
+/// Declares a command from a plain function.
+///
+/// **This macro is only available with the `extra_macros` feature.**
+///
+/// The `#[command]` attribute converts a function taking `Vec<String>` into a
+/// Mingling command by:
+/// 1. Calling `dispatcher!("command_name")` to register the dispatcher entry.
+/// 2. Generating a `#[chain]` wrapper that bridges the entry type (`Entry{Pascal}`)
+/// to the original function.
+/// 3. Preserving the original function unchanged (including attributes, extensions,
+/// visibility, and asyncness).
+///
+/// # Syntax
+///
+/// ## Simple form (auto-derives names from function name)
+///
+/// ```rust,ignore
+/// #[command]
+/// fn greet(args: Vec<String>) -> Next {
+/// // ...
+/// }
+/// ```
+///
+/// This deduces:
+/// - Command path: `"greet"` (via `dot_case` of function name)
+/// - Dispatcher struct: `CMDGreet`
+/// - Entry struct: `EntryGreet`
+/// - Dispatches via `dispatcher!("greet")`
+///
+/// ## Explicit attributes
+///
+/// ```rust,ignore
+/// #[command(node = "hello.world")]
+/// fn greet(args: Vec<String>) -> Next {
+/// // ...
+/// }
+/// // → dispatcher!("hello.world", CMDGreet => EntryGreet)
+/// ```
+///
+/// ```rust,ignore
+/// #[command(name = MyDispatcher, entry = MyEntry)]
+/// fn greet(args: Vec<String>) -> Next {
+/// // ...
+/// }
+/// // → dispatcher!("greet", MyDispatcher => MyEntry)
+/// ```
+///
+/// ## Extension attributes
+///
+/// Extra bare paths (e.g. `buffer`, `routeify`, `::mingling::macros::routeify`)
+/// are emitted as `#[ext]` attributes **on the original function**, not on the
+/// chain wrapper. The chain wrapper always uses bare `#[::mingling::macros::chain]`.
+///
+/// ```rust,ignore
+/// #[command(buffer)]
+/// fn greet(args: Vec<String>) {
+/// r_println!("Hello!");
+/// }
+/// ```
+///
+/// # Resource injection
+///
+/// Parameters after the first are treated as resource injections and passed
+/// through to the generated `#[chain]` wrapper unchanged (as reference params):
+///
+/// ```rust,ignore
+/// #[command]
+/// fn greet(args: Vec<String>, ec: &mut ResExitCode) -> Next {
+/// ec.exit_code = 0;
+/// // ...
+/// }
+/// ```
+///
+/// The generated chain wrapper calls the original function with `entry.into()`
+/// for the first argument and passes all subsequent arguments directly.
+///
+/// # Requirements
+///
+/// - The function must have at least one parameter (the `Vec<String>` entry argument).
+/// - The function must not have a `self` parameter.
+/// - Visibility (`pub` etc.) and `async` are preserved on the original function.
+#[cfg(feature = "extra_macros")]
+#[proc_macro_attribute]
+pub fn command(attr: TokenStream, item: TokenStream) -> TokenStream {
+ attr::command::command_attr(attr, item)
+}
+
/// Declares a `Dispatcher` that uses `clap::Parser` for argument parsing.
///
/// **This macro is only available with the `clap` feature.**
diff --git a/mingling_pathf/src/pattern_analyzer.rs b/mingling_pathf/src/pattern_analyzer.rs
index e88d9f6..79b1c5a 100644
--- a/mingling_pathf/src/pattern_analyzer.rs
+++ b/mingling_pathf/src/pattern_analyzer.rs
@@ -33,6 +33,7 @@ pub fn init_with_config(config: PathfinderConfig) -> PatternAnalyzer {
analyzer.add_pattern(GroupPattern);
analyzer.add_pattern(GroupedDerivePattern);
analyzer.add_pattern(ChainPattern);
+ analyzer.add_pattern(CommandPattern);
analyzer.add_pattern(RendererPattern);
analyzer.add_pattern(HelpPattern);
analyzer.add_pattern(CompletionPattern);
diff --git a/mingling_pathf/src/patterns.rs b/mingling_pathf/src/patterns.rs
index 9845b73..964fe7c 100644
--- a/mingling_pathf/src/patterns.rs
+++ b/mingling_pathf/src/patterns.rs
@@ -2,6 +2,7 @@
pub use basic_struct::*;
pub use chain::*;
+pub use command::*;
pub use completion::*;
pub use dispatcher::*;
pub use dispatcher_clap::*;
@@ -13,6 +14,7 @@ pub use renderer::*;
mod basic_struct;
mod chain;
+mod command;
mod completion;
mod dispatcher;
mod dispatcher_clap;
diff --git a/mingling_pathf/src/patterns/command.rs b/mingling_pathf/src/patterns/command.rs
new file mode 100644
index 0000000..fac70af
--- /dev/null
+++ b/mingling_pathf/src/patterns/command.rs
@@ -0,0 +1,81 @@
+//! The `CommandPattern` matches functions annotated with `#[command]` and
+//! extracts the generated hidden module name (`__command_<fn>_module`).
+//!
+//! Supported forms:
+//! ```rust,ignore
+//! #[command]
+//! fn greet(args: Vec<String>) -> Next { ... }
+//!
+//! #[command(node = "foo.foo-bar")]
+//! fn greet(args: Vec<String>) -> Next { ... }
+//!
+//! #[command(name = CMDGreet, entry = EntryGreet)]
+//! fn greet(args: Vec<String>) -> Next { ... }
+//!
+//! #[command(node = "greet", name = CMDGreet, entry = EntryGreet, buffer)]
+//! fn greet(args: Vec<String>) { ... }
+//! ```
+
+use syn::Item;
+
+use crate::pattern_analyzer::{AnalyzeItem, AnalyzePattern};
+
+/// Match `#[command]` functions.
+///
+/// The `#[command]` macro generates a hidden `__command_<fn>_module` that
+/// re-exports all internal types (`Entry*`, `CMD*`, chain struct, dispatcher
+/// static). This pattern tracks that module; the build system generates a
+/// glob re-export `use path::__command_<fn>_module::*;` to bring everything
+/// into scope.
+pub struct CommandPattern;
+
+impl AnalyzePattern for CommandPattern {
+ fn contains(&self, content: &str) -> bool {
+ content.contains("command")
+ }
+
+ fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
+ let Ok(syntax) = syn::parse_file(content) else {
+ return Vec::new();
+ };
+
+ let mut items = Vec::new();
+ for item in &syntax.items {
+ collect_from_item(item, "", &mut items);
+ }
+ items
+ }
+}
+
+fn collect_from_item(item: &Item, current_mod: &str, items: &mut Vec<AnalyzeItem>) {
+ match item {
+ Item::Fn(f) if has_command_attr(&f.attrs) => {
+ let fn_name = f.sig.ident.to_string();
+ let mod_name = format!("__command_{}_module", &fn_name);
+ items.push(AnalyzeItem::local_module(current_mod.to_string(), mod_name));
+ }
+ Item::Mod(item_mod) => {
+ if let Some((_, nested)) = &item_mod.content {
+ let mod_name = &item_mod.ident.to_string();
+ let nested_mod = if current_mod.is_empty() {
+ mod_name.clone()
+ } else {
+ format!("{current_mod}::{mod_name}")
+ };
+ for n in nested {
+ collect_from_item(n, &nested_mod, items);
+ }
+ }
+ }
+ _ => {}
+ }
+}
+
+fn has_command_attr(attrs: &[syn::Attribute]) -> bool {
+ attrs.iter().any(|a| {
+ a.path()
+ .segments
+ .last()
+ .is_some_and(|s| s.ident == "command")
+ })
+}