diff options
24 files changed, 1329 insertions, 187 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index cd2c0a3..5fe8be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,16 @@ None #### Optimizations: -None +1. **[`pathf`]** Added `is_module` field to `AnalyzeItem` and a new constructor `AnalyzeItem::local_module(module, item_name)` which sets `is_module: true`. The `type_mapping_builder` now tracks whether an item is a module: when generating `type_using.rs`, module items produce `use path::to::module::*;` (glob import) instead of the standard `use path::to::TypeName;` direct import. Non-module items continue to use direct imports as before. The internal data structure changed from `Vec<(String, String)>` to `Vec<(String, String, bool)>` to carry the `is_module` flag through the pipeline. + +2. **[`macros:gen_program`]** Wrapped all code generated by `gen_program!()` inside a `__this_program_impl` module and re-exported it with `pub use __this_program_impl::*;`. This isolates the generated internal items (type aliases, trait implementations, and pathf-generated `use` statements) from the call site's module namespace, preventing name collisions and keeping generated machinery out of the caller's direct scope. + + - The `Next` type alias, `Routable` impl for `ChainProcess<ThisProgram>`, and the `program_fallback_gen!()` / `program_final_gen!()` expansions are now all inside `pub mod __this_program_impl { ... }`, then re-exported publicly. + - Pathf integration: when the `pathf` feature is enabled, the `type_using.rs` file (generated by the build script) is loaded at compile time via `load_pathf_uses()` and emitted as `use ...;` statements **inside** the `__this_program_impl` module. Previously, pathf uses were injected via `include!()` inside the `ProgramCollect` impl block in `program_final_gen`; now they are loaded by `gen_program` itself and placed at the top of the hidden module. A `compile_error!` hint is emitted if the pathf file is missing or empty. + - When `pathf` is **disabled**, `__this_program_impl` emits `use super::*;` to bring the caller's parent scope types into the generated module, preserving existing behavior for projects that don't use pathf. + - Completion generation: removed `crate::` prefix from `CompletionSuggest` references in `program_comp_gen.rs`, since the generated code now lives inside `__this_program_impl` and no longer has a direct `crate` path to the user's crate root. The prefix became unnecessary because `CompletionSuggest` is expected to be in scope (e.g., via pathf glob re-exports or the `use super::*;` fallback). + + _No behavioral change for downstream code — all public items are re-exported with the same names. The `__this_program_impl` module is `#[doc(hidden)]` and not part of the public API._ #### Features: @@ -79,6 +88,58 @@ None Each type implements `SinglePickable`, performing filesystem validation at parse time and returning `NotFound` when the precondition is not met. +2. **[`picker:parsing`]** Added convenience methods to the internal `repeat!`-generated tuple implementations for `PickArgParsed<T1, T2, ...>` structs in `arg_picker::picker::parse`: + + - **`unwrap_or_default(self)`** — Returns the parsed values, using `Default::default()` for any missing required arguments. Panics if a route was selected. + - **`unwrap_or_else<F>(self, op: F)`** — Returns the parsed values, using the provided closure to generate default values for any missing required arguments. Panics if a route was selected. + - **`expect(self, msg: &str)`** — Returns the parsed values, or panics with the given message if a route was selected. Requires `Route: std::fmt::Debug`. + + 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. + +5. **[`macros:dispatcher`]** Added a `From<pack_Type> for crate::Entry` implementation inside the `dispatcher!()` macro expansion. When the `dispatcher!()` macro generates the entry pack type (via `pack!(#pack = Vec<String>)`), it now also generates `impl From<#pack> for crate::Entry { fn from(value: #pack) -> Self { crate::Entry::new(value.inner) } }`. This allows pack types generated by `dispatcher!()` to be directly converted into `crate::Entry`, enabling ergonomic integration with program-level entry handling. + +6. **[`macros:gen_program`]** Added a `pack!(Entry = Vec<String>)` invocation inside the `__this_program_impl` module generated by `gen_program!()`. This creates a `Entry` pack type (aliasing a `Vec<String>` container) directly in the generated module, providing a default entry point type for the program that can be used by `dispatcher!()`-generated types and other chain infrastructure without requiring the user to define a separate entry pack type manually. + #### **BREAKING CHANGES** (API CHANGES): None diff --git a/arg_picker/src/picker/parse.rs b/arg_picker/src/picker/parse.rs index 9db5bd9..4a959e3 100644 --- a/arg_picker/src/picker/parse.rs +++ b/arg_picker/src/picker/parse.rs @@ -33,6 +33,55 @@ internal_repeat!(1..=32 => { ((p.v$.expect(concat!("missing required argument at position ", $)),+)) } + /// Returns the parsed values, using the default values for any missing + /// required arguments, or panicking if a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected. + /// + /// # Type Constraints + /// + /// All types in the tuple must implement [`Default`]. + pub fn unwrap_or_default(self) -> ((T$,+)) + where + ( + T$: Default, + +) { + let p = self.parse(); + ((p.v$.unwrap_or_default(),+)) + } + + /// Returns the parsed values, using the provided closure to generate + /// default values for any missing required arguments, or panicking if + /// a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected. + pub fn unwrap_or_else<F>(self, op: F) -> ((T$,+)) + where + F: FnOnce(Route) -> ((T$,+)), + { + let r = self.to_result(); + r.unwrap_or_else(op) + } + + /// Returns the parsed values, or panics with the given message if + /// a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected, with the provided message. + /// + /// # Type Constraints + /// + /// `Route` must implement [`std::fmt::Debug`] so that the error + /// message can include the route value. + pub fn expect(self, msg: &str) -> ((T$,+)) where Route: std::fmt::Debug { + self.to_result().expect(msg) + } + /// Returns the individual option values without checking the route. pub fn unpack(self) -> ((Option<T$>,+)) { let p = self.parse(); diff --git a/arg_picker/src/picker/result.rs b/arg_picker/src/picker/result.rs index 2099825..f13f52e 100644 --- a/arg_picker/src/picker/result.rs +++ b/arg_picker/src/picker/result.rs @@ -32,6 +32,55 @@ internal_repeat!(1..=32 => { ((self.v$.expect(concat!("missing required argument at position ", $)),+)) } + /// Returns the parsed values, using the default values for any missing + /// required arguments, or panicking if a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected. + /// + /// # Type Constraints + /// + /// All types in the tuple must implement [`Default`]. + pub fn unwrap_or_default(self) -> ((T$,+)) + where + ( + T$: Default, + +) { + let p = self; + ((p.v$.unwrap_or_default(),+)) + } + + /// Returns the parsed values, using the provided closure to generate + /// default values for any missing required arguments, or panicking if + /// a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected. + pub fn unwrap_or_else<F>(self, op: F) -> ((T$,+)) + where + F: FnOnce(Route) -> ((T$,+)), + { + let r = self.to_result(); + r.unwrap_or_else(op) + } + + /// Returns the parsed values, or panics with the given message if + /// a route was selected. + /// + /// # Panics + /// + /// Panics if a route was selected, with the provided message. + /// + /// # Type Constraints + /// + /// `Route` must implement [`std::fmt::Debug`] so that the error + /// message can include the route value. + pub fn expect(self, msg: &str) -> ((T$,+)) where Route: std::fmt::Debug { + self.to_result().expect(msg) + } + /// Returns the individual option values without checking the route. pub fn unpack(self) -> ((Option<T$>,+)) { ((self.v$,+)) diff --git a/docs/example-pages/examples.json b/docs/example-pages/examples.json index 2a07365..349248e 100644 --- a/docs/example-pages/examples.json +++ b/docs/example-pages/examples.json @@ -95,6 +95,22 @@ ] }, { + "id": "example-command-macro", + "name": "Command Macro", + "icon": "🚀", + "category": "advanced", + "desc": "Introduced how to use the `#[command]` macro to generate commands with minimal boilerplate\n", + "tags": [ + "#[command]", + "dispatcher!", + "#[chain]" + ], + "files": [ + "src/main.rs", + "Cargo.toml" + ] + }, + { "id": "example-completion", "name": "Completion", "icon": "🔄", diff --git a/examples/example-command-macro/Cargo.lock b/examples/example-command-macro/Cargo.lock new file mode 100644 index 0000000..025b322 --- /dev/null +++ b/examples/example-command-macro/Cargo.lock @@ -0,0 +1,234 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arg-picker" +version = "0.1.1" +dependencies = [ + "arg-picker-macros", + "just_fmt", +] + +[[package]] +name = "arg-picker-macros" +version = "0.1.1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "example-command-macro" +version = "0.1.0" +dependencies = [ + "mingling", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "just_fmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "might_be_async" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca871cea620b07bd2e6da0c883891a25bead698c43a9ab64b0fd663a7a78d5f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "toml", +] + +[[package]] +name = "mingling" +version = "0.3.0" +dependencies = [ + "arg-picker", + "mingling_core", + "mingling_macros", +] + +[[package]] +name = "mingling_core" +version = "0.3.0" +dependencies = [ + "just_fmt", + "might_be_async", +] + +[[package]] +name = "mingling_macros" +version = "0.3.0" +dependencies = [ + "just_fmt", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] diff --git a/examples/example-command-macro/Cargo.toml b/examples/example-command-macro/Cargo.toml new file mode 100644 index 0000000..b73c2c6 --- /dev/null +++ b/examples/example-command-macro/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "example-command-macro" +version = "0.1.0" +edition = "2024" + +[dependencies.mingling] +path = "../../mingling" +features = [ + # Use `extra_macros` to introduce the `#[command]` macro + "extra_macros", + + # Use `picker` to parse arguments + "picker", +] + +[workspace] diff --git a/examples/example-command-macro/page.toml b/examples/example-command-macro/page.toml new file mode 100644 index 0000000..fe36d7a --- /dev/null +++ b/examples/example-command-macro/page.toml @@ -0,0 +1,10 @@ +[example] +id = "example-command-macro" +name = "Command Macro" +icon = "🚀" +category = "advanced" +desc = """ +Introduced how to use the `#[command]` macro to generate commands with minimal boilerplate +""" +tags = ["#[command]", "dispatcher!", "#[chain]"] +files = ["src/main.rs", "Cargo.toml"] diff --git a/examples/example-command-macro/src/main.rs b/examples/example-command-macro/src/main.rs new file mode 100644 index 0000000..2bf932a --- /dev/null +++ b/examples/example-command-macro/src/main.rs @@ -0,0 +1,66 @@ +//! Example Command Macro +//! +//! > Introduced how to use the `#[command]` macro to generate commands with minimal boilerplate +//! +//! Run: +//! ```base +//! cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- hello world +//! cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- greet-someone Alice +//! cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- goodbye +//! ``` +//! +//! Output: +//! ```plaintext +//! Hello, World +//! Hello, Alice +//! Goodbye! +//! ``` + +use mingling::{macros::buffer, picker::IntoPicker, prelude::*}; + +fn main() { + let mut program = ThisProgram::new(); + + // Import the dispatchers generated by the `#[command]` macro + program.with_dispatcher(CMDHelloWorld); + program.with_dispatcher(CMDGreetSomeone); + program.with_dispatcher(CMDGoodBye); + + program.exec_and_exit(); +} + +pack!(ResultGreeting = String); +pack!(ResultGoodbye = ()); + +// --------- IMPORTANT --------- +// Auto-generates dispatcher!("hello.world", CMDHelloWorld => EntryHelloWorld); +#[command] +fn hello_world() -> ResultGreeting { + ResultGreeting::new("World".to_string()) +} + +// Auto-generates dispatcher!("hello-world", CMDGreetSomeone => EntryGreetSomeone); +#[command(node = "greet-someone")] +fn greet_someone(args: Vec<String>) -> ResultGreeting { + let name = args.pick_or(&arg![String], || "World".to_string()).unwrap(); + ResultGreeting::new(name) +} + +// Auto-generates dispatcher!("goodbye", CMDGoodBye => EntryGoodBye); +#[command(name = CMDGoodBye, entry = EntryGoodBye)] +fn goodbye() -> ResultGoodbye { + ResultGoodbye::default() +} +// --------- IMPORTANT --------- + +#[renderer(buffer)] +fn render_greeting(result: ResultGreeting) { + r_println!("Hello, {}", *result); +} + +#[renderer(buffer)] +fn render_goodbye(_: ResultGoodbye) { + r_println!("Goodbye!"); +} + +gen_program!(); diff --git a/examples/test-examples.toml b/examples/test-examples.toml index e450919..b74c9a5 100644 --- a/examples/test-examples.toml +++ b/examples/test-examples.toml @@ -312,3 +312,18 @@ expect.result = "Result: 1.3333334" command = "calc 4 / 3 --round" expect.exit-code = 0 expect.result = "Result: 1" + +[[test.example-command-macro]] +command = "hello world" +expect.exit-code = 0 +expect.result = "Hello, World" + +[[test.example-command-macro]] +command = "greet-someone Alice" +expect.exit-code = 0 +expect.result = "Hello, Alice" + +[[test.example-command-macro]] +command = "goodbye" +expect.exit-code = 0 +expect.result = "Goodbye!" diff --git a/mingling/src/example_docs.rs b/mingling/src/example_docs.rs index 35cf024..3e9bd26 100644 --- a/mingling/src/example_docs.rs +++ b/mingling/src/example_docs.rs @@ -768,6 +768,96 @@ pub mod example_clap_binding {} /// gen_program!(); /// ``` pub mod example_combine_pathf_dispatch_tree {} +/// Example Command Macro +/// +/// > Introduced how to use the `#[command]` macro to generate commands with minimal boilerplate +/// +/// Run: +/// ```base +/// cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- hello world +/// cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- greet-someone Alice +/// cargo run --manifest-path examples/example-command-macro/Cargo.toml --quiet -- goodbye +/// ``` +/// +/// Output: +/// ```plaintext +/// Hello, World +/// Hello, Alice +/// Goodbye! +/// ``` +/// +/// Source code (./Cargo.toml) +/// ```toml +/// [package] +/// name = "example-command-macro" +/// version = "0.1.0" +/// edition = "2024" +/// +/// [dependencies.mingling] +/// path = "../../mingling" +/// features = [ +/// # Use `extra_macros` to introduce the `#[command]` macro +/// "extra_macros", +/// +/// # Use `picker` to parse arguments +/// "picker", +/// ] +/// +/// [workspace] +/// ``` +/// +/// Source code (./src/main.rs) +/// ```ignore +/// use mingling::{macros::buffer, picker::IntoPicker, prelude::*}; +/// +/// fn main() { +/// let mut program = ThisProgram::new(); +/// +/// // Import the dispatchers generated by the `#[command]` macro +/// program.with_dispatcher(CMDHelloWorld); +/// program.with_dispatcher(CMDGreetSomeone); +/// program.with_dispatcher(CMDGoodBye); +/// +/// program.exec_and_exit(); +/// } +/// +/// pack!(ResultGreeting = String); +/// pack!(ResultGoodbye = ()); +/// +/// // --------- IMPORTANT --------- +/// // Auto-generates dispatcher!("hello.world", CMDHelloWorld => EntryHelloWorld); +/// #[command] +/// fn hello_world() -> ResultGreeting { +/// ResultGreeting::new("World".to_string()) +/// } +/// +/// // Auto-generates dispatcher!("hello-world", CMDGreetSomeone => EntryGreetSomeone); +/// #[command(node = "greet-someone")] +/// fn greet_someone(args: Vec<String>) -> ResultGreeting { +/// let name = args.pick_or(&arg![String], || "World".to_string()).unwrap(); +/// ResultGreeting::new(name) +/// } +/// +/// // Auto-generates dispatcher!("goodbye", CMDGoodBye => EntryGoodBye); +/// #[command(name = CMDGoodBye, entry = EntryGoodBye)] +/// fn goodbye() -> ResultGoodbye { +/// ResultGoodbye::default() +/// } +/// // --------- IMPORTANT --------- +/// +/// #[renderer(buffer)] +/// fn render_greeting(result: ResultGreeting) { +/// r_println!("Hello, {}", *result); +/// } +/// +/// #[renderer(buffer)] +/// fn render_goodbye(_: ResultGoodbye) { +/// r_println!("Goodbye!"); +/// } +/// +/// gen_program!(); +/// ``` +pub mod example_command_macro {} /// Example Completion /// /// > This example demonstrates how to use **Mingling** to create fully dynamic command-line completions 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_core/src/renderer/render_result.rs b/mingling_core/src/renderer/render_result.rs index 8757376..fc3f2b1 100644 --- a/mingling_core/src/renderer/render_result.rs +++ b/mingling_core/src/renderer/render_result.rs @@ -1,6 +1,7 @@ use std::{ fmt::{Display, Formatter}, io::Write, + process::{ExitCode, exit}, }; use crate::RenderResultMode::{Stderr, Stdout}; @@ -99,6 +100,18 @@ macro_rules! impl_from_int { impl_from_int!(i32, i16, i8, u32, u16, u8, usize); +impl From<RenderResult> for ExitCode { + fn from(value: RenderResult) -> Self { + ExitCode::from(value.exit_code as u8) + } +} + +impl From<&RenderResult> for ExitCode { + fn from(value: &RenderResult) -> Self { + ExitCode::from(value.exit_code as u8) + } +} + impl From<&String> for RenderResult { fn from(value: &String) -> Self { string_to_render_result(value, Stdout) @@ -483,6 +496,24 @@ impl RenderResult { exit_code: self.exit_code, } } + + /// Exits the process with the exit code stored in this `RenderResult`. + /// + /// This method calls `std::process::exit()` with the `exit_code` value, + /// terminating the current process immediately. + /// + /// # Examples + /// + /// ``` + /// use mingling_core::RenderResult; + /// + /// let mut result = RenderResult::new(); + /// result.exit_code = 42; + /// // result.exit_process(); // would exit with code 42 + /// ``` + pub fn exit_process(&self) { + exit(self.exit_code) + } } #[inline(always)] 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..35c5c30 --- /dev/null +++ b/mingling_macros/src/attr/command.rs @@ -0,0 +1,365 @@ +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()); + } + + 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() +} + +/// Returns `true` if the function has a first non-reference (owned) parameter that +/// serves as the "args" input. +fn has_args_param(sig: &syn::Signature) -> bool { + sig.inputs.first().map_or(false, |arg| { + if let FnArg::Typed(pat_type) = arg { + !matches!(&*pat_type.ty, Type::Reference(_)) + } else { + false + } + }) +} + +/// Builds the wrapper function's parameter list. +/// +/// - If the function has an "args" param (first non-reference): replaces its type +/// with the entry type (e.g. `Vec<String>` → `EntryGreet`). +/// - If not (no params, or first param is a reference): inserts a new entry param +/// at the front to satisfy `#[chain]`'s requirement for an owned first parameter. +fn build_wrapper_params( + sig: &syn::Signature, + entry_type: &Ident, +) -> syn::punctuated::Punctuated<FnArg, syn::token::Comma> { + if has_args_param(sig) { + // First param is owned (args) -> replace its type with entry type + 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 + } else { + // No args param -> insert an entry param at the front, keep rest as-is + let mut params = syn::punctuated::Punctuated::new(); + let entry_param: FnArg = syn::parse_quote! { _args: #entry_type }; + params.push(entry_param); + for arg in sig.inputs.iter() { + params.push(arg.clone()); + } + params + } +} + +/// Builds call arguments for the original function call inside the wrapper. +/// +/// - If the function has an "args" param (first non-reference): first arg gets +/// `.into()` (converts `EntryGreet` → `Vec<String>`), rest pass through as-is. +/// - If not (no args): all params are resources — pass none, return empty. +fn build_call_args(sig: &syn::Signature) -> Vec<TokenStream2> { + let has_args = has_args_param(sig); + sig.inputs + .iter() + .enumerate() + .map(|(i, arg)| { + let pat = match arg { + FnArg::Typed(PatType { pat, .. }) => quote! { #pat }, + FnArg::Receiver(_) => unreachable!(), + }; + if has_args && 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 = { + let ident = Ident::new( + &format!("__internal_dispatcher_{}", snaked_node), + fn_name.span(), + ); + quote! { #vis use super::#ident; } + }; + #[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; + #dispatcher_internal + } + }; + + expanded.into() +} diff --git a/mingling_macros/src/func/dispatcher.rs b/mingling_macros/src/func/dispatcher.rs index 4e7c42f..ef02618 100644 --- a/mingling_macros/src/func/dispatcher.rs +++ b/mingling_macros/src/func/dispatcher.rs @@ -99,7 +99,7 @@ pub(crate) fn dispatcher(input: TokenStream) -> TokenStream { command_name, } => { let command_name_str = command_name.value(); - let pascal = dotted_to_pascal_case(&command_name_str); + let pascal = just_fmt::pascal_case!(&command_name_str); let command_struct = Ident::new(&format!("CMD{pascal}"), command_name.span()); let pack = Ident::new(&format!("Entry{pascal}"), command_name.span()); (command_name, command_struct, pack, cmd_attrs, Vec::new()) @@ -121,6 +121,12 @@ pub(crate) fn dispatcher(input: TokenStream) -> TokenStream { ::mingling::macros::pack!(#(#entry_attrs)* #pack = Vec<String>); + impl From<#pack> for crate::Entry { + fn from(value: #pack) -> Self { + crate::Entry::new(value.inner) + } + } + #comp_entry #dispatch_tree_entry @@ -178,21 +184,3 @@ fn get_dispatch_tree_entry( ) -> TokenStream2 { quote! {} } - -/// Converts a dotted command name (e.g. "remote.add") to `PascalCase` (e.g. "`RemoteAdd`"). -/// -/// Each segment is split by `.`, the first character of each segment is uppercased, -/// and the segments are joined. This is used by the abbreviated `dispatcher!` syntax -/// (when `Command => Entry` is omitted) to auto-derive struct names. -#[cfg(feature = "extra_macros")] -pub(crate) fn dotted_to_pascal_case(s: &str) -> String { - s.split('.') - .map(|segment| { - let mut chars = segment.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - } - }) - .collect() -} diff --git a/mingling_macros/src/func/gen_program.rs b/mingling_macros/src/func/gen_program.rs index c5358bc..d50041e 100644 --- a/mingling_macros/src/func/gen_program.rs +++ b/mingling_macros/src/func/gen_program.rs @@ -15,33 +15,100 @@ pub(crate) fn gen_program_impl(_input: TokenStream) -> TokenStream { #[cfg(not(feature = "comp"))] let comp_gen = quote! {}; + // When pathf is enabled, load the type_using.rs generated by the build script + // and emit its use statements so types from submodules are in scope. + #[cfg(feature = "pathf")] + let pathf_uses: Vec<proc_macro2::TokenStream> = { + let uses = load_pathf_uses(); + if uses.is_empty() { + // The file might not exist yet — emit a clear hint + let hint: proc_macro2::TokenStream = syn::parse_quote! { + compile_error!( + "pathf: `{}` not found or empty.\n\ + Make sure `build.rs` calls `mingling::build::analyze_and_build_type_mapping().unwrap();`\n\ + with features [\"build\", \"pathf\"] enabled." + ); + }; + vec![hint] + } else { + uses + } + }; + #[cfg(not(feature = "pathf"))] + let pathf_uses: Vec<proc_macro2::TokenStream> = Vec::new(); + + #[cfg(feature = "pathf")] + let super_use = quote! {}; + + #[cfg(not(feature = "pathf"))] + let super_use = quote! { + use super::*; + }; + TokenStream::from(quote! { - /// Alias for the current program type `crate::ThisProgram` - pub type Next = ::mingling::ChainProcess<crate::ThisProgram>; - - impl ::mingling::Routable<crate::ThisProgram> for ::mingling::ChainProcess<crate::ThisProgram> - { - fn to_chain(self) -> ::mingling::ChainProcess<crate::ThisProgram> { - match self { - ::mingling::ChainProcess::Ok((any, _)) => { - ::mingling::ChainProcess::Ok((any, mingling::NextProcess::Chain)) + pub use __this_program_impl::*; + + #[doc(hidden)] + pub mod __this_program_impl { + #super_use + #(#pathf_uses)* + + /// Alias for the current program type `ThisProgram` + pub type Next = ::mingling::ChainProcess<ThisProgram>; + + ::mingling::macros::pack!(Entry = Vec<String>); + + impl ::mingling::Routable<ThisProgram> for ::mingling::ChainProcess<ThisProgram> + { + fn to_chain(self) -> ::mingling::ChainProcess<ThisProgram> { + match self { + ::mingling::ChainProcess::Ok((any, _)) => { + ::mingling::ChainProcess::Ok((any, mingling::NextProcess::Chain)) + } + other => other, } - other => other, } - } - fn to_render(self) -> ::mingling::ChainProcess<crate::ThisProgram> { - match self { - ::mingling::ChainProcess::Ok((any, _)) => { - ::mingling::ChainProcess::Ok((any, mingling::NextProcess::Renderer)) + fn to_render(self) -> ::mingling::ChainProcess<ThisProgram> { + match self { + ::mingling::ChainProcess::Ok((any, _)) => { + ::mingling::ChainProcess::Ok((any, mingling::NextProcess::Renderer)) + } + other => other, } - other => other, } } - } - #comp_gen - ::mingling::macros::program_fallback_gen!(); - ::mingling::macros::program_final_gen!(); + #comp_gen + ::mingling::macros::program_fallback_gen!(); + ::mingling::macros::program_final_gen!(); + } }) } + +/// Loads `type_using.rs` generated by the pathf build script and returns each +/// `use ...;` line as a token stream, ready to be emitted in the generated output. +#[cfg(feature = "pathf")] +fn load_pathf_uses() -> Vec<proc_macro2::TokenStream> { + let out_dir = match std::env::var("OUT_DIR") { + Ok(d) => d, + Err(_) => return Vec::new(), + }; + let crate_name = match std::env::var("CARGO_PKG_NAME") { + Ok(n) => n, + Err(_) => return Vec::new(), + }; + let path = std::path::Path::new(&out_dir) + .join(&crate_name) + .join("type_using.rs"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + content + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .filter_map(|line| line.parse::<proc_macro2::TokenStream>().ok()) + .collect() +} diff --git a/mingling_macros/src/func/program_comp_gen.rs b/mingling_macros/src/func/program_comp_gen.rs index b6b2546..2fbb0e0 100644 --- a/mingling_macros/src/func/program_comp_gen.rs +++ b/mingling_macros/src/func/program_comp_gen.rs @@ -14,7 +14,7 @@ pub(crate) fn program_comp_gen_impl(_input: TokenStream) -> TokenStream { match read_ctx { Ok(ctx) => { let suggest = ::mingling::CompletionHelper::exec_completion::<crate::ThisProgram>(&ctx); - ::mingling::Routable::<crate::ThisProgram>::to_render(crate::CompletionSuggest::new((ctx, suggest))) + ::mingling::Routable::<crate::ThisProgram>::to_render(CompletionSuggest::new((ctx, suggest))) } Err(_) => std::process::exit(1), } @@ -32,7 +32,7 @@ pub(crate) fn program_comp_gen_impl(_input: TokenStream) -> TokenStream { match read_ctx { Ok(ctx) => { let suggest = ::mingling::CompletionHelper::exec_completion::<crate::ThisProgram>(&ctx); - ::mingling::Routable::<crate::ThisProgram>::to_render(crate::CompletionSuggest::new((ctx, suggest))) + ::mingling::Routable::<crate::ThisProgram>::to_render(CompletionSuggest::new((ctx, suggest))) } Err(_) => std::process::exit(1), } diff --git a/mingling_macros/src/func/program_final_gen.rs b/mingling_macros/src/func/program_final_gen.rs index d3af3b3..0eed1db 100644 --- a/mingling_macros/src/func/program_final_gen.rs +++ b/mingling_macros/src/func/program_final_gen.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use proc_macro::TokenStream; use quote::quote; @@ -37,48 +35,12 @@ fn parse_entry_pair(entry: &proc_macro2::TokenStream) -> (proc_macro2::Ident, pr (struct_ident, variant_ident) } -/// Loads the pathf type mapping from `$OUT_DIR/{crate}/type_using.rs`. -fn load_pathf_map() -> Option<std::collections::HashMap<String, String>> { - if !cfg!(feature = "pathf") { - return None; - } - let out_dir = std::env::var("OUT_DIR").ok()?; - let crate_name = std::env::var("CARGO_PKG_NAME").ok()?; - let path = std::path::Path::new(&out_dir) - .join(&crate_name) - .join("type_using.rs"); - let content = std::fs::read_to_string(&path).ok()?; - Some( - content - .lines() - .filter_map(|line| { - let line = line.trim(); - if let Some(rest) = line.strip_prefix("use ") { - let path = rest.strip_suffix(';').unwrap_or(rest); - if let Some((_mod, type_name)) = path.rsplit_once("::") { - return Some((type_name.to_string(), path.to_string())); - } - } - None - }) - .collect(), - ) -} - -/// Resolves a type name to its full path token stream using the pathf mapping. -pub(crate) fn resolve_type( - name: &str, - map: &std::collections::HashMap<String, String>, -) -> proc_macro2::TokenStream { - if let Some(full_path) = map.get(name) { - syn::parse_str::<proc_macro2::TokenStream>(full_path).unwrap_or_else(|_| { - let ident = proc_macro2::Ident::new(name, proc_macro2::Span::call_site()); - quote! { #ident } - }) - } else { - let ident = proc_macro2::Ident::new(name, proc_macro2::Span::call_site()); - quote! { #ident } - } +/// Helper: convert a string ident into a token stream for the generated code. +/// Types are expected to be in scope (e.g. via pathf glob re-exports), so bare +/// idents suffice. +fn ident_tokens(name: &str) -> proc_macro2::TokenStream { + let ident = proc_macro2::Ident::new(name, proc_macro2::Span::call_site()); + quote! { #ident } } #[allow(clippy::too_many_lines)] @@ -126,45 +88,6 @@ pub(crate) fn program_final_gen_impl(_input: TokenStream) -> TokenStream { .map(|s| syn::parse_str::<proc_macro2::TokenStream>(s).unwrap()) .collect(); - let pathf_map: Option<HashMap<String, String>> = load_pathf_map(); - - #[cfg(feature = "pathf")] - let pathf_hint: proc_macro2::TokenStream = if pathf_map.is_none() { - quote! { - compile_error!( -"Cannot load type mapping computed by `pathf`. -If not yet configured, execute `mingling::build::analyze_and_build_type_mapping()` in your `build.rs`. - -fn main() { - // Require features: [\"builds\", \"pathf\"] - mingling::build::analyze_and_build_type_mapping().unwrap(); - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\\__ Write to `build.rs` - -} - "); - } - } else { - quote! {} - }; - - #[cfg(not(feature = "pathf"))] - let pathf_hint: proc_macro2::TokenStream = quote! {}; - - let pathf_map: HashMap<String, String> = if cfg!(feature = "pathf") { - pathf_map.unwrap_or_default() - } else { - HashMap::new() - }; - - let pathf_uses: Vec<proc_macro2::TokenStream> = if cfg!(feature = "pathf") { - pathf_map - .values() - .map(|path| format!("use {};", path).parse().unwrap_or_default()) - .collect() - } else { - Vec::new() - }; - #[cfg(feature = "structural_renderer")] let structural_renderer_tokens: Vec<proc_macro2::TokenStream> = structural_renderers .iter() @@ -177,13 +100,9 @@ fn main() { any: ::mingling::AnyOutput<Self::Enum>, setting: &::mingling::StructuralRendererSetting, ) -> Result<::mingling::RenderResult, ::mingling::error::StructuralRendererSerializeError> { - #[allow(unused_imports)] - #(#pathf_uses)* match any.member_id() { #(#structural_renderer_tokens)* _ => { - // Non-structural types: render ResultEmpty (which implements - // StructuralData + Serialize) instead of producing nothing. let mut r = ::mingling::RenderResult::default(); ::mingling::StructuralRenderer::render(&ResultEmpty, setting, &mut r)?; Ok(r) @@ -222,8 +141,8 @@ fn main() { }) .collect(); - let get_nodes_fn = dispatch_tree_gen::gen_get_nodes(&entries, &pathf_map); - let dispatch_trie_fn = dispatch_tree_gen::gen_dispatch_args_trie(&entries, &pathf_map); + let get_nodes_fn = dispatch_tree_gen::gen_get_nodes(&entries); + let dispatch_trie_fn = dispatch_tree_gen::gen_dispatch_args_trie(&entries); quote! { #get_nodes_fn @@ -243,8 +162,6 @@ fn main() { #[cfg(feature = "comp")] let comp = quote! { fn do_comp(any: &::mingling::AnyOutput<Self::Enum>, ctx: &::mingling::ShellContext) -> ::mingling::Suggest { - #[allow(unused_imports)] - #(#pathf_uses)* match any.member_id() { #(#completion_tokens)* _ => ::mingling::Suggest::FileCompletion, @@ -266,12 +183,10 @@ fn main() { } else { let render_arms: Vec<_> = renderer_tokens.iter().map(|entry| { let (struct_ident, variant_ident) = parse_entry_pair(entry); - let downcast_ty = resolve_type(&variant_ident.to_string(), &pathf_map); - let resolved_struct = resolve_type(&struct_ident.to_string(), &pathf_map); + let downcast_ty = ident_tokens(&variant_ident.to_string()); + let resolved_struct = ident_tokens(&struct_ident.to_string()); quote! { Self::#variant_ident => { - // SAFETY: The `type_id` check ensures that `any` contains a value of type `#variant_ident`, - // so downcasting to `#variant_ident` is safe. let value = unsafe { any.downcast::<#downcast_ty>().unwrap_unchecked() }; <#resolved_struct as ::mingling::Renderer>::render(value) } @@ -290,12 +205,10 @@ fn main() { // Build do_chain function (async and sync versions) let chain_arms_async: Vec<_> = chain_tokens.iter().map(|entry| { let (struct_ident, variant_ident) = parse_entry_pair(entry); - let downcast_ty = resolve_type(&variant_ident.to_string(), &pathf_map); - let resolved_struct = resolve_type(&struct_ident.to_string(), &pathf_map); + let downcast_ty = ident_tokens(&variant_ident.to_string()); + let resolved_struct = ident_tokens(&struct_ident.to_string()); quote! { Self::#variant_ident => { - // SAFETY: The `type_id` check ensures that `any` contains a value of type `#variant_ident`, - // so downcasting to `#variant_ident` is safe. let value = unsafe { any.downcast::<#downcast_ty>().unwrap_unchecked() }; let fut = async { <#resolved_struct as ::mingling::Chain<Self::Enum>>::proc(value).await }; ::std::boxed::Box::pin(fut) @@ -307,12 +220,10 @@ fn main() { .iter() .map(|entry| { let (struct_ident, variant_ident) = parse_entry_pair(entry); - let downcast_ty = resolve_type(&variant_ident.to_string(), &pathf_map); - let resolved_struct = resolve_type(&struct_ident.to_string(), &pathf_map); + let downcast_ty = ident_tokens(&variant_ident.to_string()); + let resolved_struct = ident_tokens(&struct_ident.to_string()); quote! { Self::#variant_ident => { - // SAFETY: The `type_id` check ensures that `any` contains a value of type `#variant_ident`, - // so downcasting to `#variant_ident` is safe. let value = unsafe { any.downcast::<#downcast_ty>().unwrap_unchecked() }; <#resolved_struct as ::mingling::Chain<Self::Enum>>::proc(value) } @@ -370,8 +281,6 @@ fn main() { }; let expanded = quote! { - #pathf_hint - #[derive(Debug, PartialEq, Eq, Clone, Copy)] #[repr(#repr_type)] #[allow(nonstandard_style)] @@ -392,6 +301,7 @@ fn main() { type ErrorDispatcherNotFound = ErrorDispatcherNotFound; type ErrorRendererNotFound = ErrorRendererNotFound; type ResultEmpty = ResultEmpty; + fn build_renderer_not_found(member_id: Self::Enum) -> ::mingling::AnyOutput<Self::Enum> { ::mingling::AnyOutput::new(ErrorRendererNotFound::new(member_id.to_string())) } @@ -404,8 +314,6 @@ fn main() { #render_fn #do_chain_fn fn render_help(any: ::mingling::AnyOutput<Self::Enum>) -> ::mingling::RenderResult { - #[allow(unused_imports)] - #(#pathf_uses)* match any.member_id() { #(#help_tokens)* _ => ::mingling::RenderResult::default(), 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_macros/src/systems/dispatch_tree_gen.rs b/mingling_macros/src/systems/dispatch_tree_gen.rs index 9eeb637..fe44a49 100644 --- a/mingling_macros/src/systems/dispatch_tree_gen.rs +++ b/mingling_macros/src/systems/dispatch_tree_gen.rs @@ -1,27 +1,22 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; use just_fmt::snake_case; use proc_macro2::TokenStream; use quote::quote; -use crate::func::program_final_gen::resolve_type; - /// Generate the `get_nodes()` function body for a ProgramCollect impl. -/// If `pathf_map` is non-empty, resolves internal dispatcher statics using full paths. -pub(crate) fn gen_get_nodes( - entries: &[(String, String, String)], - pathf_map: &HashMap<String, String>, -) -> TokenStream { +pub(crate) fn gen_get_nodes(entries: &[(String, String, String)]) -> TokenStream { let mut node_entries = Vec::new(); for (node_name, _disp_type, _entry_name) in entries { let static_name_str = format!("__internal_dispatcher_{}", snake_case!(node_name)); - let resolved = resolve_type(&static_name_str, pathf_map); + let static_ident = + proc_macro2::Ident::new(&static_name_str, proc_macro2::Span::call_site()); let node_display_name = node_name.replace('.', " "); let node_display_lit = syn::LitStr::new(&node_display_name, proc_macro2::Span::call_site()); node_entries.push(quote! { - (#node_display_lit.to_string(), & #resolved) + (#node_display_lit.to_string(), &#static_ident) }); } @@ -38,20 +33,13 @@ pub(crate) fn gen_get_nodes( /// /// Builds a hardcoded match tree: at each depth, group nodes by character. /// Single-node groups use `starts_with`; multi-node groups recurse with `nth()` match. -/// -/// If `pathf_map` is non-empty, resolves dispatcher types using full paths. -pub(crate) fn gen_dispatch_args_trie( - entries: &[(String, String, String)], - pathf_map: &HashMap<String, String>, -) -> TokenStream { - // Prepare (display_name, disp_type) pairs. - // display_name = node_name.replace('.', " ") +pub(crate) fn gen_dispatch_args_trie(entries: &[(String, String, String)]) -> TokenStream { let nodes: Vec<(String, String)> = entries .iter() .map(|(name, disp, _)| (name.replace('.', " "), disp.clone())) .collect(); - let dispatch_body = build_dispatch_body(&nodes, 0, pathf_map); + let dispatch_body = build_dispatch_body(&nodes, 0); quote! { fn dispatch_args_trie( @@ -70,19 +58,13 @@ pub(crate) fn gen_dispatch_args_trie( /// /// `nodes`: slice of (display_name, disp_type) for commands that share the same prefix so far. /// `depth`: The character index currently being matched. -/// `pathf_map`: optional mapping from type name to full path for resolving dispatchers. -fn build_dispatch_body( - nodes: &[(String, String)], - depth: usize, - pathf_map: &HashMap<String, String>, -) -> TokenStream { +fn build_dispatch_body(nodes: &[(String, String)], depth: usize) -> TokenStream { if nodes.is_empty() { return quote! { return Ok(Self::build_dispatcher_not_found(raw.to_vec())); }; } - // Group by character at `depth` let mut groups: BTreeMap<char, Vec<(String, String)>> = BTreeMap::new(); let mut exact_nodes: Vec<(String, String)> = Vec::new(); @@ -97,18 +79,17 @@ fn build_dispatch_body( } } - // Build a dispatch arm for a single node via `starts_with` let make_starts_with_arm = |name: &str, disp_type: &str| -> TokenStream { let name_space = format!("{} ", name); let name_lit = syn::LitStr::new(&name_space, proc_macro2::Span::call_site()); - let disp_resolved = resolve_type(disp_type, pathf_map); + let disp_ident = proc_macro2::Ident::new(disp_type, proc_macro2::Span::call_site()); let prefix_word_count = name.split_whitespace().count(); quote! { if raw_str.starts_with(#name_lit) { let prefix_len = #prefix_word_count; let trimmed_args: Vec<String> = raw.iter().skip(prefix_len).cloned().collect(); - let __cp = <#disp_resolved as ::mingling::Dispatcher<Self::Enum>>::begin( - &#disp_resolved::default(), + let __cp = <#disp_ident as ::mingling::Dispatcher<Self::Enum>>::begin( + &#disp_ident::default(), trimmed_args, ); return match __cp { @@ -136,7 +117,7 @@ fn build_dispatch_body( } }); } else { - let sub_body = build_dispatch_body(sub_nodes, depth + 1, pathf_map); + let sub_body = build_dispatch_body(sub_nodes, depth + 1); arms.push(quote! { Some(#ch_char) => { #sub_body diff --git a/mingling_pathf/src/pattern_analyzer.rs b/mingling_pathf/src/pattern_analyzer.rs index 5b25c15..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); @@ -50,6 +51,8 @@ pub struct AnalyzeItem { pub item_name: String, /// Whether the item is from an external crate (resolved via `use`), bypassing the file's own module path. pub is_foreign: bool, + /// When `true`, this item is a module whose contents should be glob-imported (`::*`). + pub is_module: bool, } impl AnalyzeItem { @@ -59,6 +62,17 @@ impl AnalyzeItem { module, item_name, is_foreign: false, + is_module: false, + } + } + + /// Creates a local module item — generates a `use path::item_name::*;` glob import. + pub fn local_module(module: String, item_name: String) -> Self { + Self { + module, + item_name, + is_foreign: false, + is_module: true, } } @@ -68,6 +82,7 @@ impl AnalyzeItem { module, item_name, is_foreign: true, + is_module: false, } } } 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") + }) +} diff --git a/mingling_pathf/src/type_mapping_builder.rs b/mingling_pathf/src/type_mapping_builder.rs index f6f57a5..fb59b43 100644 --- a/mingling_pathf/src/type_mapping_builder.rs +++ b/mingling_pathf/src/type_mapping_builder.rs @@ -25,7 +25,7 @@ pub fn analyze_and_build_type_mapping_for( let module_mapping = module_pathf::analyze(crate_dir)?; let analyzer = pattern_analyzer::init_with_config(config.clone()); - let mut type_mappings: Vec<(String, String)> = Vec::new(); + let mut type_mappings: Vec<(String, String, bool)> = Vec::new(); for item in module_mapping { let file_abs = crate_dir.join(item.file_path()); @@ -40,14 +40,13 @@ pub fn analyze_and_build_type_mapping_for( for ai in analyze_items { let full_path = if ai.is_foreign { - // Foreign item — use its own module path as-is format!("{}::{}", ai.module, ai.item_name) } else if ai.module.is_empty() { format!("{}::{}", module_path, ai.item_name) } else { format!("{}::{}::{}", module_path, ai.module, ai.item_name) }; - type_mappings.push((ai.item_name, full_path)); + type_mappings.push((ai.item_name, full_path, ai.is_module)); } } @@ -56,7 +55,7 @@ pub fn analyze_and_build_type_mapping_for( // Deduplicate by type name, keeping the first occurrence let mut seen = HashSet::new(); - type_mappings.retain(|(name, _)| seen.insert(name.clone())); + type_mappings.retain(|(name, _, _)| seen.insert(name.clone())); // Create output directory std::fs::create_dir_all(output_dir)?; @@ -66,14 +65,18 @@ pub fn analyze_and_build_type_mapping_for( let type_using_path = output_dir.join("type_using.rs"); let mut content_mapping = String::new(); - for (name, path) in &type_mappings { + for (name, path, _) in &type_mappings { content_mapping.push_str(&format!("{name} = {path}\n")); } std::fs::write(&output_path, content_mapping)?; let mut content_using = String::new(); - for (_, path) in &type_mappings { - content_using.push_str(&format!("use {path};\n")); + for (_, path, is_module) in &type_mappings { + if *is_module { + content_using.push_str(&format!("use {path}::*;\n")); + } else { + content_using.push_str(&format!("use {path};\n")); + } } std::fs::write(&type_using_path, content_using)?; |
