aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/settings.json30
-rw-r--r--.zed/settings.json52
-rw-r--r--CHANGELOG.md236
-rw-r--r--GETTING_STARTED.md2
-rw-r--r--docs/_zh_CN/pages/13-hook.md2
-rw-r--r--docs/pages/13-hook.md2
-rw-r--r--examples/example-hook/src/main.rs2
-rw-r--r--examples/example-unit-test/src/main.rs8
-rw-r--r--mingling/src/example_docs.rs10
-rw-r--r--mingling/src/lib.rs30
-rw-r--r--mingling/src/setups/repl_basic.rs2
-rw-r--r--mingling_cli/.gitignore1
-rw-r--r--mingling_cli/Cargo.lock556
-rw-r--r--mingling_cli/Cargo.toml60
-rw-r--r--mingling_cli/build.rs12
-rw-r--r--mingling_cli/pre/lint_registry.rs208
-rw-r--r--mingling_cli/pre/mod.rs2
-rw-r--r--mingling_cli/src/diagnostic.rs99
-rw-r--r--mingling_cli/src/errors.rs1
-rw-r--r--mingling_cli/src/errors/serde_json.rs8
-rw-r--r--mingling_cli/src/linter.rs67
-rw-r--r--mingling_cli/src/linter/cmd_mlint.rs119
-rw-r--r--mingling_cli/src/linter/mlint_attr.rs39
-rw-r--r--mingling_cli/src/linter/mlint_report.rs445
-rw-r--r--mingling_cli/src/lints.rs82
-rw-r--r--mingling_cli/src/lints/non_mingling_naming_style.rs352
-rw-r--r--mingling_cli/src/lints/template_linter.rs56
-rw-r--r--mingling_cli/src/lints/unnecessary_render_result_creation.rs490
-rw-r--r--mingling_cli/src/main.rs29
-rw-r--r--mingling_cli/src/message.rs10
-rw-r--r--mingling_cli/src/metadata.rs24
-rw-r--r--mingling_cli/src/metadata/cmd_metadata.rs17
-rw-r--r--mingling_cli/src/metadata/setup.rs136
-rw-r--r--mingling_cli/tmpls/lints.tmpl78
-rw-r--r--mingling_core/src/any.rs122
-rw-r--r--mingling_core/src/any/group.rs12
-rw-r--r--mingling_core/src/asset/dispatcher.rs6
-rw-r--r--mingling_core/src/asset/global_resource.rs6
-rw-r--r--mingling_core/src/lib.rs8
-rw-r--r--mingling_core/src/program/collection/mock.rs7
-rw-r--r--mingling_core/src/program/hook.rs15
-rw-r--r--mingling_core/src/program/once_exec.rs38
-rw-r--r--mingling_core/src/program/setup.rs3
-rw-r--r--mingling_core/src/renderer/render_result.rs497
-rw-r--r--mingling_core/src/renderer/structural.rs16
-rw-r--r--mingling_core/src/renderer/structural/structural_data.rs8
-rw-r--r--mingling_core/tests/test-all/tests/integration.rs17
-rw-r--r--mingling_core/tests/test-basic/tests/integration.rs2
-rw-r--r--mingling_core/tests/test-dispatch-tree/tests/integration.rs2
-rw-r--r--mingling_core/tests/test-repl/tests/integration.rs2
-rw-r--r--mingling_core/tests/test-structural-renderer/tests/integration.rs8
-rw-r--r--mingling_macros/src/attr/chain.rs22
-rw-r--r--mingling_macros/src/derive/grouped.rs12
-rw-r--r--mingling_macros/src/extensions.rs4
-rw-r--r--mingling_macros/src/extensions/renderify.rs48
-rw-r--r--mingling_macros/src/func/gen_program.rs22
-rw-r--r--mingling_macros/src/func/group.rs6
-rw-r--r--mingling_macros/src/func/pack.rs6
-rw-r--r--mingling_macros/src/func/pack_err.rs4
-rw-r--r--mingling_macros/src/func/r_print.rs65
-rw-r--r--mingling_macros/src/lib.rs191
-rw-r--r--mingling_macros/src/systems/structural_data.rs24
-rw-r--r--mingling_pathf/src/pattern_analyzer.rs22
-rw-r--r--mingling_pathf/src/patterns/basic_struct.rs13
-rw-r--r--mingling_pathf/src/patterns/chain.rs10
-rw-r--r--mingling_pathf/src/patterns/completion.rs10
-rw-r--r--mingling_pathf/src/patterns/dispatcher.rs15
-rw-r--r--mingling_pathf/src/patterns/dispatcher_clap.rs65
-rw-r--r--mingling_pathf/src/patterns/group.rs116
-rw-r--r--mingling_pathf/src/patterns/grouped_derive.rs31
-rw-r--r--mingling_pathf/src/patterns/help.rs10
-rw-r--r--mingling_pathf/src/patterns/pack.rs15
-rw-r--r--mingling_pathf/src/patterns/renderer.rs10
-rw-r--r--mingling_pathf/src/type_mapping_builder.rs5
74 files changed, 4408 insertions, 354 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 8b49198..71ced58 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,19 +1,15 @@
{
- "rust-analyzer.check.command": "clippy",
- "rust-analyzer.checkOnSave": true,
- "rust-analyzer.linkedProjects": [
- ".run/Cargo.toml",
- "mingling_pathf/test/Cargo.toml",
- "arg_picker/Cargo.toml",
- "arg_picker/test/Cargo.toml",
- ],
- "rust-analyzer.cargo.features": [
- "structural_renderer",
- "mingling_support",
- "all_serde_fmt",
- "docs_rs",
- "picker",
- "parser",
- "comp",
- ],
+ "rust-analyzer.check.command": "clippy",
+ "rust-analyzer.checkOnSave": true,
+ "rust-analyzer.files.exclude": ["**/target/**", "**/.temp/**"],
+ "rust-analyzer.linkedProjects": [
+ ".run/Cargo.toml",
+ "mingling_pathf/test/Cargo.toml",
+ "arg_picker/Cargo.toml",
+ "arg_picker/test/Cargo.toml",
+ "mingling_cli/Cargo.toml"
+ ],
+ "rust-analyzer.cargo.features": [],
+ "rust-analyzer.procMacro.enable": true,
+ "rust-analyzer.procMacro.attributes.enable": true
}
diff --git a/.zed/settings.json b/.zed/settings.json
index 6469826..7378109 100644
--- a/.zed/settings.json
+++ b/.zed/settings.json
@@ -1,27 +1,29 @@
{
- "lsp": {
- "rust-analyzer": {
- "initialization_options": {
- "checkOnSave": true,
- "check": { "command": "clippy" },
- "linkedProjects": [
- ".run/Cargo.toml",
- "mingling_pathf/test/Cargo.toml",
- "arg_picker/Cargo.toml",
- "arg_picker/test/Cargo.toml",
- ],
- "cargo": {
- "features": [
- "structural_renderer",
- "mingling_support",
- "all_serde_fmt",
- "docs_rs",
- "picker",
- "parser",
- "comp",
- ],
- },
- },
- },
- },
+ "lsp": {
+ "rust-analyzer": {
+ "initialization_options": {
+ "checkOnSave": true,
+ "check": { "command": "clippy" },
+ "files": {
+ "exclude": ["**/target/**", "**/.temp/**"]
+ },
+ "linkedProjects": [
+ ".run/Cargo.toml",
+ "mingling_pathf/test/Cargo.toml",
+ "arg_picker/Cargo.toml",
+ "arg_picker/test/Cargo.toml",
+ "mingling_cli/Cargo.toml"
+ ],
+ "cargo": {
+ "features": []
+ },
+ "procMacro": {
+ "enable": true,
+ "attributes": {
+ "enable": true
+ }
+ }
+ }
+ }
+ }
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b90536e..86584cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -52,9 +52,34 @@ None
### Release 0.3.0 (Unreleased)
+> In detail, the changes in Mingling 0.3.0 are as follows:
+
+1. **Added `arg-picker`** — Mingling has never had a comfortable argument parsing solution. You either suffered with `parser` or went all-in on `clap`. So I wrote a smarter `arg-picker`. The API style is close to the original `parser`, but it's more type-safe, more robust, and more extensible. See the main text for details.
+
+2. **Made implicit behavior explicit**. For a long time, Mingling's attribute macros have been making **implicit** modifications to the original function — I have to admit, that's dirty. In the new version, I've removed **all** implicit modifications to the original function by attribute macros. In other words, `#[chain]`, `#[renderer]`, `#[help]`, and `#[completion]` will no longer modify your original function in any way unless you explicitly specify it. Use the Extension Attribute (`#[chain(/* ... */)]`) mechanism to explicitly inject implicit behavior into your functions.
+
+3. I was originally planning to remove `r_println!` because I couldn't stand that `__renderer_inner_result` thing implicitly injected by the `#[renderer]` macro. But now I've rewritten it: `#[buffer]` injects an implicit `__render_result_buffer` value into the function, and then `r_println!` calls it. It's an extra step, but it also means: the dirt is your choice, not something I'm forcing on you :)
+
+4. **Finally**, a philosophical point. Mingling will move forward with a preference for **"selectively dirty"** over **"invisibly, forcibly dirty"** — this is the biggest direction going forward, building a more comfortable API on this foundation.
+
#### Fixes:
-None
+1. **[`pathf:patterns`]** Fixed pattern detection logic in `mingling_pathf` for multiple patterns to correctly detect opening bracket forms (e.g., `[chain`, `[renderer`, `[help`, `[completion`) in addition to the previously supported closing bracket forms (e.g., `chain]`, `renderer]`, `help]`, `completion]`). This ensures that attribute macro usages like `#[chain]`, `#[renderer]`, `#[help]`, and `#[completion]` are properly detected regardless of which side of the attribute the pattern matcher examines.
+
+ - **`ChainPattern`**: Changed `content.contains("chain]")` → `content.contains("[chain") || content.contains("chain]")`
+ - **`RendererPattern`**: Changed `content.contains("renderer]")` → `content.contains("[renderer") || content.contains("renderer]")`
+ - **`HelpPattern`**: Changed `content.contains("help]")` → `content.contains("[help") || content.contains("help]")`
+ - **`CompletionPattern`**: Changed `content.contains("completion(")` → `content.contains("completion(") || content.contains("[completion")`
+
+2. **[`pathf:patterns`]** Updated `PackPattern` detection to recognize `pack_structural!` and `pack_err_structural!` macros, which were previously missed by the pattern matcher. The `contains` method now checks for these additional macro names alongside the existing `pack!` and `pack_err!` checks.
+
+3. **[`pathf:patterns`]** Added `is_foreign` field to `AnalyzeItem` struct in `mingling_pathf`, along with constructor helpers `AnalyzeItem::local()` and `AnalyzeItem::foreign()`. The `foreign()` constructor marks items resolved via `use` imports, so their `module` path is used as-is (rather than being prefixed with the file's module path) in `type_mapping_builder.rs`.
+
+ - **`GroupPattern`** updated to collect `use` imports at the file and inline-module level via `collect_use_imports()` and `collect_from_use_tree()`, and to resolve `group!(TypeName)` invocations against those imports: if a name matches an imported type, it is emitted as `AnalyzeItem::foreign()` with the full import path; if the `Alias = path::Type` form is used, it is emitted as `AnalyzeItem::local()` (the alias lives in-crate); otherwise it is `local()`.
+
+ - All other patterns (`BasicStructPattern`, `ChainPattern`, `CompletionPattern`, `DispatcherPattern`, `DispatcherClapPattern`, `GroupedDerivePattern`, `HelpPattern`, `PackPattern`, `RendererPattern`) updated to use `AnalyzeItem::local()` constructors, preserving existing behavior (all items are treated as local/in-crate).
+
+ - **`type_mapping_builder.rs`** updated to check `ai.is_foreign`: when true, the full path is built as `{module}::{item_name}` (no prefix from the file's own module path); when false, the existing logic applies (`{file_module_path}::{module}::{item_name}` or `{file_module_path}::{item_name}`).
#### Optimizations:
@@ -89,6 +114,56 @@ None
_No migration is required for downstream code — the behavior of all four macros is unchanged. This is purely an internal code generation refactoring._
+4. **[`core`]** Changed the `exec_without_render` and `exec` methods' behavior: the `print!`-based output with manual stdout flushing has been replaced by a call to `result.std_print()`, which handles buffered output internally via the new `RenderResult` buffer system (introduced in **BREAKING CHANGE #6**). The `render_output` gating still applies — when `stdout_setting.render_output` is `false`, nothing is printed — but the `!result.is_empty()` guard has been removed: `std_print()` now handles empty results appropriately.
+
+ Previously, when `render_output` was true but `result.is_empty()` was also true, the old code would skip printing entirely and return the exit code from the result. Now, `std_print()` is called unconditionally when `render_output` is true, and the exit code is read from the result regardless of whether any output was printed. This ensures that programs which set a non-zero exit code without producing renderable output (e.g., via `ExitCodeSetup` or `ProgramControlUnit::OverrideExitCode`) will exit with the correct code.
+
+5. **[`core`]** Made `with_resource`, `with_dispatcher`, `with_dispatchers`, `with_hook`, and `with_setup` methods return `&mut Self` instead of `()`. These methods now return a mutable reference to the program instance, enabling ergonomic chaining:
+
+ ```rust
+ // Before — required separate statements
+ program.with_resource(ResConfig::new());
+ program.with_resource(ResDatabase::new());
+ program.with_setup(BasicProgramSetup);
+ program.with_hook(logging_hook);
+
+ // After — supports method chaining
+ program
+ .with_resource(ResConfig::new())
+ .with_resource(ResDatabase::new())
+ .with_setup(BasicProgramSetup)
+ .with_hook(logging_hook);
+ ```
+
+ Affected methods:
+ - `Program::with_resource(&mut self, res: Res) -> &mut Self` — Insert a resource into the program's global resource store.
+ - `Program::with_dispatcher(&mut self, dispatcher: Disp) -> &mut Self` — Add a single dispatcher to the program.
+ - `Program::with_dispatchers(&mut self, dispatchers: D) -> &mut Self` — Add multiple dispatchers to the program.
+ - `Program::with_hook(&mut self, hook: ProgramHook<C>) -> &mut Self` — Add a lifecycle hook to the program.
+ - `Program::with_setup(&mut self, setup: S) -> &mut Self` — Load and execute a program setup.
+
+ _No behavioral changes — all existing functionality is preserved. Downstream code that ignores the return value continues to work without modification._
+
+6. **[`core`]** **`StructuralData` trait now takes a generic parameter `C`.** The `StructuralData` trait and its sealed supertrait `StructuralDataSealed` (both under `::mingling::__private`) have been made generic over a program collector type `C: ProgramCollect<Enum = C>`. This change is necessary for `group_structural!` to bypass the orphan rule — by tying `StructuralData<C>` to `crate::ThisProgram` (which is defined in the user's crate), external types can implement `StructuralData<crate::ThisProgram>` without violating coherence.
+
+ **Migration guide (only relevant for manual `StructuralData` implementations):**
+
+ - All `impl StructuralData for MyType` must be updated to `impl StructuralData<crate::ThisProgram> for MyType`.
+ - All `impl StructuralDataSealed for MyType` must be updated to `impl StructuralDataSealed<crate::ThisProgram> for MyType`.
+ - All trait bounds `T: StructuralData` must be updated to `T: StructuralData<C>` with an additional `C: ProgramCollect<Enum = C>` bound.
+ - The `StructuralRenderer::render` method signature has changed from `render<T: StructuralData + Send>(...)` to `render<T, C>(...) where T: StructuralData<C> + Send, C: ProgramCollect<Enum = C>`.
+
+ **Internal changes:**
+
+ - `StructuralDataSealed` in `mingling_core::__private` now takes a `C` type parameter with `C: ProgramCollect<Enum = C>`.
+ - `StructuralData` in `mingling_core::renderer::structural::structural_data` now takes a `C` type parameter with `C: ProgramCollect<Enum = C>`.
+ - Both traits remain under `::mingling::__private`, so this change does **not** affect the public API surface.
+ - `StructuralRenderer::render` now takes an additional generic parameter `C: ProgramCollect<Enum = C>`.
+ - All derive macro and `pack_structural!` / `pack_err_structural!` / `group_structural!` implementations have been updated to emit `impl StructuralDataSealed<crate::ThisProgram>` and `impl StructuralData<crate::ThisProgram>` instead of the non-generic form.
+ - Test code has been updated to use `MockProgramCollect` where appropriate, and integration tests now use `crate::ThisProgram` and call `gen_program!()`.
+
+ _No behavioral changes — this is purely a type-system refactoring to enable `group_structural!` to work with external types. Since both traits are defined in `::mingling::__private`, this change has **no impact on the public API** — end users interact with `StructuralData` only through auto-generated derive macros and `pack_structural!`/`group_structural!` macros, which are automatically updated. Only users with manual `impl StructuralData` blocks (an advanced/rare case) need to update their code.
+
#### Features:
1. **[`core`]** Added `RenderResult::new()` method for creating a new `RenderResult` with default values (empty text and exit code 0). This provides a more explicit and discoverable constructor compared to `RenderResult::default()`, making it clearer when a fresh result is being created for use with `write!`/`writeln!`.
@@ -272,6 +347,86 @@ None
- Updated `try_redispatch_simple` and `try_redispatch_completion` to emit `#[#exts]` instead of bare `#exts`, ensuring proper attribute syntax in the re-dispatched token stream.
- Registered `#[proc_macro_attribute] pub fn routeify` in `mingling_macros/src/lib.rs`.
+12. **[`core`]** **[`macros`]** Added the `r_append!` macro and `RenderResult::append_other()` method for appending the contents of one `RenderResult` to another. The `append_other()` method on `RenderResult` merges the buffered content (text and output modes) from another `RenderResult` into the current one. If the destination result has `immediate_output` enabled but the source does not, the source's content will be immediately flushed to the appropriate output stream (stdout/stderr) while also being appended to the render buffer. The `exit_code` of the source result is **not** transferred — only the buffered content and the `immediate_output` flag are merged.
+
+ The `r_append!` macro supports two usage forms:
+ - **Explicit buffer** — `r_append!(dst, src)` appends the contents of `src` into the `dst` `RenderResult`.
+ - **Implicit buffer** — `r_append!(src)` appends the contents of `src` into the implicit `__render_result_buffer` (available inside `#[buffer]` functions).
+
+ The macro is re-exported as `mingling::macros::r_append` and included in `mingling::prelude::*`.
+
+13. **[`core`]** **[`macros`]** Added `From<F>` implementation for `RenderResult` where `F: FnOnce() -> RenderResult`. This enables `RenderResult` to be constructed from a closure or function pointer that returns a `RenderResult`, enabling ergonomic composition of render buffers.
+
+ This is particularly useful with the `#[buffer]` extension attribute, which creates a separate buffer function that can be called from within a `#[renderer(buffer)]` function using `r_append!`:
+
+ ```rust
+ use mingling::macros::{buffer, r_append, r_println};
+
+ // A standalone buffer function
+ #[buffer]
+ fn print_sth() {
+ r_println!("ok");
+ }
+
+ #[renderer(buffer)]
+ fn render_greet(_: ResultGreet) {
+ r_append!(print_sth); // appends `a`'s RenderResult content into `p`'s buffer
+ }
+ ```
+
+ Under the hood, `r_append!` (when used in implicit buffer mode inside a `#[buffer]` function) calls `append_other` on the current render buffer. The `From<F>` implementation allows the buffer function's return value (a `RenderResult`) to be seamlessly converted into the parent's buffer via `append_other`. This enables clean separation of render logic into reusable buffer functions that can be composed together.
+
+14. **[`core`]** **[`macros`]** Added the `render_route!` macro and `#[renderify]` extension attribute macro, providing error routing to the rendering pipeline (as opposed to `route!`/`#[routeify]` which route to the chain pipeline).
+
+ The `render_route!` macro is conceptually similar to `route!`, but instead of routing errors through `Routable::to_chain()` (returning `ChainProcess`), it routes them directly to the renderer via `crate::ThisProgram::render(AnyOutput::new(e))` (returning `RenderResult`). This makes it suitable for use in `#[renderer]` and `#[help]` functions where the return type is `RenderResult`.
+
+ ```rust,ignore
+ use mingling::macros::{renderer, render_route};
+
+ #[renderer]
+ fn render_something(prev: SomeType) -> RenderResult {
+ let data = render_route!(fetch_data().map_err(|e| ErrorEntry::new(e.to_string())))?;
+ // ... render data
+ Ok(RenderResult::new())
+ }
+ ```
+
+ The `#[renderify]` extension attribute is the rendering-pipeline counterpart to `#[routeify]`. It transforms `expr?` into `render_route!(expr)` (instead of `route!(expr)`), enabling concise error routing in renderer and help functions using the `?` operator syntax.
+
+ ```rust,ignore
+ #[renderer(renderify)]
+ fn render_greeting(prev: Greeting) -> RenderResult {
+ let data = load_data()?; // expands to render_route!(load_data())
+ r_println!("{data}");
+ Ok(RenderResult::new())
+ }
+ ```
+
+ The `#[renderify]` macro can be used:
+ - **Standalone** — as a direct attribute: `#[renderify] fn render(...) { ... }`
+ - **As an extension** — via the extension point system: `#[renderer(renderify)] fn render(...) { ... }` or `#[help(renderify)] fn help(...) { ... }`
+
+ When used as a renderer/help extension, the `renderify` identifier is detected by the extension point mechanism, stripped from the attribute arguments, and `#[renderify]` is applied as an outer attribute on top of `#[renderer]`/`#[help]` — just like `routeify` works with `#[chain]`.
+
+ Both `render_route!` and `#[renderify]` are feature-gated behind `extra_macros` and re-exported as `mingling::macros::render_route` and `mingling::macros::renderify` respectively.
+
+ Internal changes:
+ - Added `mingling_macros/src/extensions/renderify.rs` with `renderify_impl` implementation.
+ - Registered `#[proc_macro] pub fn render_route` and `#[proc_macro_attribute] pub fn renderify` in `mingling_macros/src/lib.rs`.
+
+15. **[`macros`]** Added the `#[mlint(...)]` marker attribute macro — a no-op attribute that passes its attached item through unchanged. The attribute content is ignored by `rustc` and reserved for the Mingling lint (`mlint`) tooling system.
+
+ The `#[mlint]` attribute is registered as a `#[proc_macro_attribute]` and re-exported as `mingling::macros::mlint`. It supports three styles of lint configuration:
+
+ ```rust,ignore
+ #[mlint(allow(MLINT_SOME_LINT))]
+ #[mlint(warn(MLINT_SOME_LINT))]
+ #[mlint(deny(MLINT_SOME_LINT))]
+ fn some_item() {}
+ ```
+
+ Since the attribute is a no-op at compile time, it has no effect on code generation, type checking, or runtime behavior. Its purpose is to serve as a structured annotation that `mlint` tooling can parse from the AST. The attribute is feature-gated behind `extra_macros`.
+
#### **BREAKING CHANGES** (API CHANGES):
1. **[`macros:renderer`]** **[`macros:help`]** Removed `r_println!` and `r_print!` macros from being implicitly injected by `#[renderer]` and `#[help]` macros. These macros still exist, but must now be used **explicitly** — either with an explicit buffer argument, or via the `#[buffer]` extension attribute that re-enables implicit buffer injection.
@@ -367,6 +522,85 @@ None
_No behavioral changes — this is a pure API migration from `Grouped` to `Routable` for routing methods._
+6. **[`core`]** **[BREAKING]** Removed the `Deref<Target = str>` implementation from `RenderResult`. Previously, `RenderResult` implemented `Deref<Target = str>`, delegating to the internal `render_text: String` field. This implementation has been removed as part of the internal refactoring of `RenderResult` from a single `String` field to a `Vec<(String, RenderResultMode)>` buffer.
+
+ The `RenderResult` struct has been restructured internally:
+
+ - **Removed:** `render_text: String` — replaced by a buffered storage format
+ - **Added:** `render_buffer: Vec<(String, RenderResultMode)>` — a list of (text, output-mode) pairs
+ - **Added:** `immediate_output: bool` — flag for real-time flushing
+
+ **New `RenderResultMode` enum** (`Stdout` / `Stderr`) has been added to distinguish output streams at the buffer level. Both variants are re-exported as `mingling::RenderResultMode`.
+
+ **New methods added to `RenderResult`:**
+
+ - `append_to_buffer(text, mode)` / `append_line_to_buffer(text, mode)` — Append text with an explicit output mode (`Stdout` or `Stderr`)
+ - `eprint(text)` / `eprintln(text)` — Append text marked for stderr output
+ - `immediate_output()` — Enable real-time output flushing
+ - `std_print()` — Flush all buffered content to stdout/stderr
+ - `len()` / `is_empty()` — Character count and emptiness check (based on the buffer)
+ - `trim_buffer(self) -> RenderResult` — Trim whitespace from the buffer ends, returning a new `RenderResult`
+
+ **`r_eprint!` and `r_eprintln!` macros** have been added to `mingling_macros` and re-exported via `mingling::macros::r_eprint` / `mingling::macros::r_eprintln` and `mingling::prelude::*`. These work analogously to `r_print!` / `r_println!` but target stderr output (calling `RenderResult::eprint` / `RenderResult::eprintln` under the hood). Both support implicit buffer mode (inside `#[buffer]` functions) and explicit buffer mode (passing a `RenderResult` as the first argument).
+
+ The `Display` implementation for `RenderResult` is now: `write!(f, "{}", render_result_to_string(self).trim())` — this trims leading and trailing whitespace from the rendered output, making formatting more predictable and avoiding stray newlines or spaces in display contexts.
+
+ **Migration guide:**
+
+ Code that relied on `Deref<Target = str>` (e.g., `&*result`, `result.as_ref()`, or passing `&RenderResult` where `&str` was expected) must be updated to use one of the following:
+
+ ```rust
+ // Before — relied on Deref
+ fn takes_str(s: &str) { /* ... */ }
+ takes_str(&result);
+
+ // After — convert explicitly
+ let result_string: String = result.to_string();
+ takes_str(&result_string);
+
+ // Or use the Display impl directly
+ println!("{}", result);
+ ```
+
+ The `is_empty()` method now checks the buffer length (in characters) rather than checking `render_text.is_empty()`. The `Display` implementation no longer adds a trailing newline (previously `writeln!` was used; now `write!` is used) — existing code that relied on the trailing newline in `Display` may need adjustment. Additionally, the `to_string()` call on `RenderResult` now trims leading and trailing whitespace from the rendered text via the `Display` implementation, whereas previously the raw content was preserved without trimming.
+
+ All examples and internal usages have been updated across the codebase to reflect these changes (e.g., `repl_basic_setup` now calls `println!("{}", r.result)` instead of `println!("{}", r.result.trim())`, since `Display` no longer adds a trailing newline).
+
+7. **[`any`]** **[`macros`]** Made `AnyOutput`'s `type_id` and `member_id` fields private (`pub(crate)`) and added public accessor methods `type_id()` and `member_id()`. Added the `unsafe fn new_bare<T>(value: T, member_id: G) -> Self` constructor that bypasses the `Grouped` trait, allowing manual specification of `member_id` without requiring the concrete type to implement `Grouped`.
+
+ - **`type_id`** field changed from `pub` to `pub(crate)` — accessible via `type_id()` accessor.
+ - **`member_id`** field changed from `pub` to `pub(crate)` — accessible via `member_id()` accessor (requires `G: Copy`).
+ - **`new_bare`** — Unsafe constructor that takes a raw `member_id` value without invoking `Grouped::member_id()`. The caller must ensure the provided `member_id` correctly corresponds to the concrete type `T`.
+ - Updated all internal `match any.member_id { ... }` patterns in `gen_program.rs` to use `match any.member_id() { ... }` instead.
+ - Updated the panic message in `do_chain` (both sync and async) from `any.type_id` to `any.type_id()`.
+ - Updated the example-hook `main.rs` to call `info.output.member_id()` instead of accessing `info.output.member_id` directly.
+ - Added `Copy` derive to the generated enum to enable `member_id()`'s `Copy` requirement on the enum type.
+
+ _No behavioral changes for existing code — the accessor methods provide the same values as the previously-public fields._
+
+8. **[`any`]** **[`macros`]** **[BREAKING]** Marked `Grouped` trait as `unsafe trait`. The `Grouped` trait has always been inherently unsafe — the `member_id()` return value must exactly correspond to the variant registered by `register_type!` for the concrete type, otherwise dispatching on that type will result in **undefined behavior**. This unsoundness has existed since the trait's inception but was previously unenforced at the type system level.
+
+ By making `Grouped` an `unsafe trait`, implementors must now explicitly acknowledge this safety contract with `unsafe impl Grouped<...> for ...`. This change makes the existing safety invariant visible to developers and enables soundness warnings at compile time.
+
+ **Changes made:**
+
+ - **`Grouped` trait** in `mingling_core/src/any/group.rs` changed from `pub trait Grouped<Group>` to `pub unsafe trait Grouped<Group>`, with a safety doc comment explaining that manually implementing the trait with an incorrect `member_id` leads to undefined behavior.
+
+ - **Derive macros** (`#[derive(Grouped)]`, `#[derive(GroupedSerialize)]`) now generate `unsafe impl` instead of `impl`, with a SAFETY comment stating that the derive macro guarantees correctness because the `Ident` used in `register_type!` matches the `Ident` returned by `member_id()`.
+
+ - **`pack!`, `pack_structural!`, `group!`, `group_structural!`** macros now generate `unsafe impl` instead of `impl`, with analogous SAFETY comments.
+
+ - **All manual test implementations** of `Grouped` across the codebase (in `any.rs` tests, `hook.rs` tests, `mock.rs`) updated to `unsafe impl` with SAFETY comments explaining why they are safe in their test contexts.
+
+ - **`MockProgramCollect::member_id()`** changed from `MockProgramCollect::Foo` to `panic!("Attempting to read an unsafe enum type")` to prevent accidental execution in production paths.
+
+ **Migration guide:**
+
+ - Existing code that uses `Grouped` only through the derive macro or `pack!`/`group!` macros is automatically migrated — no changes needed.
+ - Code with **manual** `impl Grouped<...> for ...` blocks must add `unsafe` before `impl` and verify that the `member_id()` return value correctly corresponds to the type's registered variant. Only proceed if the correspondence is guaranteed.
+
+ _This is a breaking change only for code with manual `Grouped` implementations._
+
---
## Release 0.2.2 (2026-07-10)
diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md
index 1aad8d5..621471e 100644
--- a/GETTING_STARTED.md
+++ b/GETTING_STARTED.md
@@ -551,7 +551,7 @@ fn main() {
.on_pre_chain(|info| {
println!("[DEBUG] Pre chain: {}", info.input);
})
- .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id))
+ .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id()))
.on_finish(|_| {
println!("[DEBUG] Loop end");
ProgramControlUnit::OverrideExitCode(0) // Override exit code
diff --git a/docs/_zh_CN/pages/13-hook.md b/docs/_zh_CN/pages/13-hook.md
index 6d6018a..ad3008a 100644
--- a/docs/_zh_CN/pages/13-hook.md
+++ b/docs/_zh_CN/pages/13-hook.md
@@ -71,7 +71,7 @@ fn main() {
eprintln!("[hook] executing chain for: {}", info.input);
})
.on_post_chain(|info| {
- eprintln!("[hook] chain output: {}", info.output.member_id);
+ eprintln!("[hook] chain output: {}", info.output.member_id());
}),
);
diff --git a/docs/pages/13-hook.md b/docs/pages/13-hook.md
index 26f8712..d927a9c 100644
--- a/docs/pages/13-hook.md
+++ b/docs/pages/13-hook.md
@@ -71,7 +71,7 @@ fn main() {
eprintln!("[hook] executing chain for: {}", info.input);
})
.on_post_chain(|info| {
- eprintln!("[hook] chain output: {}", info.output.member_id);
+ eprintln!("[hook] chain output: {}", info.output.member_id());
}),
);
diff --git a/examples/example-hook/src/main.rs b/examples/example-hook/src/main.rs
index 23b87c7..da92045 100644
--- a/examples/example-hook/src/main.rs
+++ b/examples/example-hook/src/main.rs
@@ -40,7 +40,7 @@ fn main() {
.on_pre_chain(|info| {
println!("[DEBUG] Pre chain: {}", info.input);
})
- .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id))
+ .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id()))
.on_finish(|_| {
println!("[DEBUG] Loop end");
ProgramControlUnit::OverrideExitCode(0) // Override exit code
diff --git a/examples/example-unit-test/src/main.rs b/examples/example-unit-test/src/main.rs
index b85ff01..e9169df 100644
--- a/examples/example-unit-test/src/main.rs
+++ b/examples/example-unit-test/src/main.rs
@@ -42,25 +42,25 @@ mod tests {
#[test]
fn test_render_result_name() {
let r = render_result_name(ResultName::new("Peter".into()));
- assert_eq!(r.to_string().as_str(), "Hello, Peter!\n")
+ assert_eq!(r.to_string().as_str(), "Hello, Peter!")
}
#[test]
fn test_render_error_no_name_provided() {
let r = render_error_no_name_provided(ErrorNoNameProvided::default());
- assert_eq!(r.to_string().as_str(), "No name provided\n")
+ assert_eq!(r.to_string().as_str(), "No name provided")
}
#[test]
fn test_render_error_name_not_available() {
let r = render_error_name_not_available(ErrorNameNotAvailable::default());
- assert_eq!(r.to_string().as_str(), "Name not available\n")
+ assert_eq!(r.to_string().as_str(), "Name not available")
}
#[test]
fn test_render_error_name_too_long() {
let r = render_error_name_too_long(ErrorNameTooLong::new(17));
- assert_eq!(r.to_string().as_str(), "Name too long: 17 > 10\n")
+ assert_eq!(r.to_string().as_str(), "Name too long: 17 > 10")
}
// --------- IMPORTANT ---------
}
diff --git a/mingling/src/example_docs.rs b/mingling/src/example_docs.rs
index bdefb5a..89d5af1 100644
--- a/mingling/src/example_docs.rs
+++ b/mingling/src/example_docs.rs
@@ -1649,7 +1649,7 @@ pub mod example_help {}
/// .on_pre_chain(|info| {
/// println!("[DEBUG] Pre chain: {}", info.input);
/// })
-/// .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id))
+/// .on_post_chain(|info| println!("[DEBUG] Post chain: {}", info.output.member_id()))
/// .on_finish(|_| {
/// println!("[DEBUG] Loop end");
/// ProgramControlUnit::OverrideExitCode(0) // Override exit code
@@ -2796,25 +2796,25 @@ pub mod example_structural_renderer {}
/// #[test]
/// fn test_render_result_name() {
/// let r = render_result_name(ResultName::new("Peter".into()));
-/// assert_eq!(r.to_string().as_str(), "Hello, Peter!\n")
+/// assert_eq!(r.to_string().as_str(), "Hello, Peter!")
/// }
///
/// #[test]
/// fn test_render_error_no_name_provided() {
/// let r = render_error_no_name_provided(ErrorNoNameProvided::default());
-/// assert_eq!(r.to_string().as_str(), "No name provided\n")
+/// assert_eq!(r.to_string().as_str(), "No name provided")
/// }
///
/// #[test]
/// fn test_render_error_name_not_available() {
/// let r = render_error_name_not_available(ErrorNameNotAvailable::default());
-/// assert_eq!(r.to_string().as_str(), "Name not available\n")
+/// assert_eq!(r.to_string().as_str(), "Name not available")
/// }
///
/// #[test]
/// fn test_render_error_name_too_long() {
/// let r = render_error_name_too_long(ErrorNameTooLong::new(17));
-/// assert_eq!(r.to_string().as_str(), "Name too long: 17 > 10\n")
+/// assert_eq!(r.to_string().as_str(), "Name too long: 17 > 10")
/// }
/// // --------- IMPORTANT ---------
/// }
diff --git a/mingling/src/lib.rs b/mingling/src/lib.rs
index f5362d5..fd274aa 100644
--- a/mingling/src/lib.rs
+++ b/mingling/src/lib.rs
@@ -75,6 +75,9 @@ pub mod macros {
pub use mingling_macros::group_structural;
/// `#[help]` - Used to generate a struct implementing the `HelpRequest` trait via a method
pub use mingling_macros::help;
+ /// `#[mlint(...)]` - Marker attribute for the Mingling lint system.
+ /// Content is ignored by rustc and reserved for mlint tooling.
+ pub use mingling_macros::mlint;
/// `node!("remote.rm")` - Used to create a `Node` struct via a literal
pub use mingling_macros::node;
/// `pack!(StateGreet = String)` - Used to create a wrapper type for use with `Chain` and `Renderer`
@@ -98,6 +101,15 @@ pub mod macros {
/// `#[program_setup]` - Used to generate program setup
#[cfg(feature = "extra_macros")]
pub use mingling_macros::program_setup;
+ /// `r_append!` - Appends the contents of one `RenderResult` to another.
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_append;
+ /// `r_eprint!` - Prints text to a `RenderResult` error buffer (without newline).
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_eprint;
+ /// `r_eprintln!` - Prints text to a `RenderResult` error buffer (with newline).
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_eprintln;
/// `r_print!` - Prints text to a `RenderResult` buffer (without newline).
/// See the macro documentation for implicit vs. explicit buffer usage.
pub use mingling_macros::r_print;
@@ -114,8 +126,17 @@ pub mod macros {
pub use mingling_macros::register_renderer;
#[doc(hidden)]
pub use mingling_macros::register_type;
+ /// `render_route! { /* ... */ }` - Routes errors to the rendering pipeline.
+ /// Similar to `route!`, but used in `#[renderer]` and `#[help]` functions
+ /// where the return type is `RenderResult` instead of `ChainProcess`.
+ #[cfg(feature = "extra_macros")]
+ pub use mingling_macros::render_route;
/// `#[renderer]` - Used to generate a struct implementing the `Renderer` trait via a method
pub use mingling_macros::renderer;
+ /// `#[renderify]` - An extension attribute macro that transforms `expr?` into `render_route!(expr)`.
+ /// Can be used standalone or as a renderer/help extension: `#[renderer(renderify, ...)]`, `#[help(renderify, ...)]`.
+ #[cfg(feature = "extra_macros")]
+ pub use mingling_macros::renderify;
/// `route! { /* ... */ }` - Used to generate a route that either returns a successful result or early returns an error.
#[cfg(feature = "extra_macros")]
pub use mingling_macros::route;
@@ -229,6 +250,15 @@ pub mod prelude {
/// Like `pack!` but also marks the type for structured output
#[cfg(all(feature = "macros", feature = "structural_renderer"))]
pub use mingling_macros::pack_structural;
+ /// `r_append!` - Appends the contents of one `RenderResult` to another.
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_append;
+ /// `r_eprint!` - Prints text to a `RenderResult` error buffer (without newline).
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_eprint;
+ /// `r_eprintln!` - Prints text to a `RenderResult` error buffer (with newline).
+ /// See the macro documentation for implicit vs. explicit buffer usage.
+ pub use mingling_macros::r_eprintln;
/// `r_print!` - Prints text to a `RenderResult` buffer (without newline).
/// See the macro documentation for implicit vs. explicit buffer usage.
pub use mingling_macros::r_print;
diff --git a/mingling/src/setups/repl_basic.rs b/mingling/src/setups/repl_basic.rs
index cf04372..150048b 100644
--- a/mingling/src/setups/repl_basic.rs
+++ b/mingling/src/setups/repl_basic.rs
@@ -75,7 +75,7 @@ where
fn setup(self, program: &mut Program<C>) {
program.with_hook(ProgramHook::empty().on_repl_receive_result(|r| {
if !r.result.is_empty() {
- println!("{}", r.result.trim())
+ println!("{}", r.result)
}
}));
}
diff --git a/mingling_cli/.gitignore b/mingling_cli/.gitignore
new file mode 100644
index 0000000..6a6d575
--- /dev/null
+++ b/mingling_cli/.gitignore
@@ -0,0 +1 @@
+registry.json
diff --git a/mingling_cli/Cargo.lock b/mingling_cli/Cargo.lock
new file mode 100644
index 0000000..e617d0a
--- /dev/null
+++ b/mingling_cli/Cargo.lock
@@ -0,0 +1,556 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "annotate-snippets"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1"
+dependencies = [
+ "anstyle",
+ "memchr",
+ "unicode-width",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "arg-picker"
+version = "0.1.0"
+dependencies = [
+ "arg-picker-macros",
+ "just_fmt 0.2.0",
+]
+
+[[package]]
+name = "arg-picker-macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "camino"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "derive_builder",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "just_fmt"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5454cda0d57db59778608d7a47bff5b16c6705598265869fb052b657f66cf05e"
+
+[[package]]
+name = "just_fmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6170dccbc3ea15dfb7f2da964097f814aba1dd8f746d4ffc56f33245c38e6d96"
+
+[[package]]
+name = "just_template"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fb99a3c1dee7299c57b26ef927f15535da0fbc93d6deb1d1114cae1337be4fb"
+dependencies = [
+ "just_fmt 0.1.2",
+ "just_template_macros",
+]
+
+[[package]]
+name = "just_template_macros"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1471eb68722ecefeb71debdde2859e8725341f171d3f42b3a98a0862ad19416e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.187"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7743783ea728ef5c31194c6590797eed286449b4a4e87d626d8a51f0a94e732"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mingling"
+version = "0.3.0"
+dependencies = [
+ "arg-picker",
+ "mingling_core",
+ "mingling_macros",
+]
+
+[[package]]
+name = "mingling-cli"
+version = "0.3.0"
+dependencies = [
+ "annotate-snippets",
+ "cargo_metadata",
+ "just_template",
+ "mingling",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "syn 3.0.2",
+ "tokio",
+]
+
+[[package]]
+name = "mingling_core"
+version = "0.3.0"
+dependencies = [
+ "just_fmt 0.2.0",
+ "mingling_pathf",
+]
+
+[[package]]
+name = "mingling_macros"
+version = "0.3.0"
+dependencies = [
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mingling_pathf"
+version = "0.3.0"
+dependencies = [
+ "just_fmt 0.2.0",
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[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.2",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.2",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/mingling_cli/Cargo.toml b/mingling_cli/Cargo.toml
new file mode 100644
index 0000000..5b36c6b
--- /dev/null
+++ b/mingling_cli/Cargo.toml
@@ -0,0 +1,60 @@
+[package]
+name = "mingling-cli"
+version = "0.3.0"
+edition = "2024"
+license = "MIT OR Apache-2.0"
+repository = "https://github.com/mingling-rs/mingling/tree/main/mingling_cli"
+authors = ["Weicao-CatilGrass"]
+readme = "README.md"
+
+description = "Mingling's scaffolding tool for generating, analyzing, and modifying Mingling projects"
+keywords = ["cli", "cli-framework", "command-line", "tools"]
+categories = ["command-line-interface"]
+
+[[bin]]
+name = "mling"
+path = "src/main.rs"
+
+[dependencies.mingling]
+path = "../mingling"
+features = [
+ "extra_macros",
+ "picker",
+ "pathf",
+ "async",
+]
+
+[build-dependencies.mingling]
+path = "../mingling"
+features = [
+ "builds",
+ "pathf",
+]
+
+[dependencies]
+
+# Project analyze
+cargo_metadata = { version = "0.23.1", features = ["builder"] }
+annotate-snippets = "0.12.16"
+
+# Code analyze
+proc-macro2 = { version = "1.0.107", features = ["span-locations"] }
+syn = { version = "3.0.2", features = ["full", "extra-traits"] }
+quote = "1.0.47"
+
+# Configure & Serialization
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+
+# Parallelism
+tokio = { version = "1.53.1", features = ["full"] }
+
+[build-dependencies]
+# Configure & Serialization
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+
+# Code gen
+just_template = "0.2.0"
+
+[workspace]
diff --git a/mingling_cli/build.rs b/mingling_cli/build.rs
new file mode 100644
index 0000000..ec4f208
--- /dev/null
+++ b/mingling_cli/build.rs
@@ -0,0 +1,12 @@
+use mingling::build::analyze_and_build_type_mapping;
+
+pub mod pre;
+
+fn main() {
+ // Perform path analysis and build type mapping table
+ analyze_and_build_type_mapping().ok();
+
+ // Generate lint registry
+ pre::gen_mod_file().unwrap();
+ pre::gen_lint_registry().unwrap();
+}
diff --git a/mingling_cli/pre/lint_registry.rs b/mingling_cli/pre/lint_registry.rs
new file mode 100644
index 0000000..e69592b
--- /dev/null
+++ b/mingling_cli/pre/lint_registry.rs
@@ -0,0 +1,208 @@
+use std::{fs, io::Error};
+
+use just_template::{Template, tmpl};
+
+/// Generate lint module registry file (src/lints/mod.rs)
+///
+/// Read all Rust source files in the src/lints/ directory (excluding mod.rs itself),
+/// automatically generate module declarations and pub use export statements, and write them to mod.rs.
+pub fn gen_mod_file() -> Result<(), Error> {
+ let root = std::env::current_dir()?;
+ let lints_dir = root.join("src").join("lints");
+ let tmpl_file = root.join("tmpls").join("lints.tmpl");
+ let mod_file = root.join("src").join("lints.rs");
+
+ let mut template = Template::from(fs::read_to_string(tmpl_file).unwrap());
+
+ // Collect all .rs file names (without extension), excluding mod
+ let mut entries: Vec<String> = fs::read_dir(&lints_dir)?
+ .filter_map(|e| e.ok())
+ .filter(|e| e.path().is_file())
+ .filter_map(|e| {
+ e.path()
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .map(|s| s.to_string())
+ })
+ .filter(|name| name != "mod")
+ .collect();
+
+ entries.sort();
+
+ // Generate module declarations and re-export statements
+ for name in &entries {
+ tmpl!(template, impls {
+ mod_name = name
+ })
+ }
+
+ // Generate run_all_lints call arms
+ for name in &entries {
+ tmpl!(template, calls {
+ name = name
+ })
+ }
+
+ // Generate file-level lint calls (lints that have `pub fn check_file`)
+ for name in &entries {
+ let file_path = lints_dir.join(format!("{name}.rs"));
+ let has_check_file = fs::read_to_string(&file_path)
+ .map(|c| c.contains("pub fn check_file("))
+ .unwrap_or(false);
+ if has_check_file {
+ tmpl!(template, file_calls {
+ name = name
+ });
+ tmpl!(template, file_lints {
+ name = name
+ });
+ }
+ }
+
+ fs::write(&mod_file, template.to_string())?;
+
+ Ok(())
+}
+
+/// Generate lint metadata registry
+///
+/// Parses each `.rs` file in `src/lints/` (excluding mod.rs and _init.rs),
+/// extracts doc-comment metadata, and writes the result as JSON.
+pub fn gen_lint_registry() -> Result<(), Error> {
+ let root = std::env::current_dir()?;
+ let lints_dir = root.join("src").join("lints");
+
+ let mut lints: Vec<serde_json::Value> = Vec::new();
+
+ for entry in fs::read_dir(&lints_dir)? {
+ let entry = entry?;
+ let path = entry.path();
+ if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("rs") {
+ continue;
+ }
+ let stem = match path.file_stem().and_then(|s| s.to_str()) {
+ Some(s) => s.to_string(),
+ None => continue,
+ };
+ if stem == "mod" {
+ continue;
+ }
+
+ let content = fs::read_to_string(&path)?;
+ if let Some(meta) = parse_lint_file(&content, &stem) {
+ lints.push(meta);
+ }
+ }
+
+ lints.sort_by(|a, b| {
+ a["name"]
+ .as_str()
+ .unwrap_or("")
+ .cmp(b["name"].as_str().unwrap_or(""))
+ });
+
+ let json = serde_json::json!({ "lints": lints });
+ let out_path = root.join("registry.json");
+ fs::write(&out_path, serde_json::to_string_pretty(&json).unwrap())?;
+ Ok(())
+}
+
+/// Parse a single lint `.rs` file and return its metadata as JSON.
+fn parse_lint_file(content: &str, name: &str) -> Option<serde_json::Value> {
+ // Collect leading doc comment lines (//!)
+ let mut doc_lines: Vec<String> = Vec::new();
+ for line in content.lines() {
+ let trimmed = line.trim();
+ if let Some(rest) = trimmed.strip_prefix("//!") {
+ doc_lines.push(rest.trim().to_string());
+ } else if !doc_lines.is_empty() {
+ break;
+ }
+ }
+
+ // Title: first non-empty doc line
+ let title = doc_lines
+ .iter()
+ .find(|l| !l.is_empty())
+ .cloned()
+ .unwrap_or_default();
+
+ // Summary: between ## Summary and ## Metadata
+ let mut summary = String::new();
+ let mut in_summary = false;
+ for line in &doc_lines {
+ if line.contains("## Summary") {
+ in_summary = true;
+ continue;
+ }
+ if line.contains("## Metadata") {
+ in_summary = false;
+ }
+ if in_summary && !line.starts_with("## ") {
+ if !summary.is_empty() {
+ summary.push('\n');
+ }
+ summary.push_str(line.trim());
+ }
+ }
+
+ // Metadata section
+ let mut author = String::new();
+ let mut default_level = String::from("warn");
+ let mut active_on = String::from("File");
+ let mut in_metadata = false;
+
+ for line in &doc_lines {
+ if line.contains("## Metadata") {
+ in_metadata = true;
+ continue;
+ }
+ if in_metadata {
+ if line.starts_with("## ") {
+ break;
+ }
+ if let Some(val) = line
+ .strip_prefix("Author:")
+ .or_else(|| line.strip_prefix("Author:"))
+ {
+ author = val.trim().trim_matches('`').to_string();
+ }
+ if let Some(val) = line
+ .strip_prefix("Default:")
+ .or_else(|| line.strip_prefix("Default:"))
+ {
+ let raw = val.trim().trim_matches('`');
+ if raw == "warn" || raw == "allow" || raw == "deny" {
+ default_level = raw.to_string();
+ }
+ }
+ }
+ }
+
+ // Extract active_on from function signature (last occurrence = actual fn)
+ if let Some(pos) = content.rfind("pub fn linter(") {
+ let after = &content[pos..];
+ if let Some(colon) = after.find(':') {
+ let type_part = &after[colon + 1..].trim();
+ let raw = type_part
+ .trim_start_matches("syn::")
+ .split([' ', ')', ',', '\n'])
+ .next()
+ .unwrap_or("File");
+ // Strip "Item" prefix: ItemFn → Fn, ItemStruct → Struct, etc.
+ active_on = raw.strip_prefix("Item").unwrap_or(raw).to_string();
+ }
+ }
+
+ let mut meta = serde_json::Map::new();
+ meta.insert("author".into(), serde_json::Value::String(author));
+ meta.insert("default".into(), serde_json::Value::String(default_level));
+ meta.insert("active_on".into(), serde_json::Value::String(active_on));
+
+ Some(serde_json::json!({
+ "name": name,
+ "title": title,
+ "summary": summary.trim(),
+ "metadata": meta,
+ }))
+}
diff --git a/mingling_cli/pre/mod.rs b/mingling_cli/pre/mod.rs
new file mode 100644
index 0000000..8eacef4
--- /dev/null
+++ b/mingling_cli/pre/mod.rs
@@ -0,0 +1,2 @@
+mod lint_registry;
+pub use lint_registry::*;
diff --git a/mingling_cli/src/diagnostic.rs b/mingling_cli/src/diagnostic.rs
new file mode 100644
index 0000000..527e879
--- /dev/null
+++ b/mingling_cli/src/diagnostic.rs
@@ -0,0 +1,99 @@
+use annotate_snippets::level::{ERROR, HELP, NOTE, WARNING};
+use annotate_snippets::{AnnotationKind, Group, Renderer, Snippet};
+use cargo_metadata::diagnostic::Diagnostic;
+use mingling::macros::{buffer, group, r_println, renderer};
+
+group!(Diagnostic);
+
+#[renderer(buffer)]
+pub fn render_diagnostic(diagnostic: Diagnostic) {
+ let report = diagnostic_to_report(&diagnostic);
+ let renderer = Renderer::styled();
+ let rendered = renderer.render(&report);
+ r_println!("{rendered}");
+}
+
+fn cargo_level_to_annotate(
+ level: cargo_metadata::diagnostic::DiagnosticLevel,
+) -> &'static annotate_snippets::Level<'static> {
+ use cargo_metadata::diagnostic::DiagnosticLevel;
+ match level {
+ DiagnosticLevel::Ice | DiagnosticLevel::Error => &ERROR,
+ DiagnosticLevel::Warning => &WARNING,
+ DiagnosticLevel::Note | DiagnosticLevel::FailureNote => &NOTE,
+ DiagnosticLevel::Help => &HELP,
+ _ => &ERROR,
+ }
+}
+
+/// 把 1-based char offset 转成 0-based byte offset
+fn char_offset_to_byte_offset(s: &str, char_offset: usize) -> usize {
+ s.char_indices()
+ .nth(char_offset.saturating_sub(1))
+ .map(|(i, _)| i)
+ .unwrap_or(s.len())
+}
+
+fn diagnostic_to_report<'a>(d: &'a Diagnostic) -> Vec<Group<'a>> {
+ let level = cargo_level_to_annotate(d.level);
+ let mut title = level.clone().primary_title(d.message.as_str());
+ if let Some(ref code) = d.code {
+ title = title.id(code.code.as_str());
+ }
+
+ let mut group = Group::with_title(title);
+
+ for span in &d.spans {
+ if !span.is_primary {
+ continue;
+ }
+
+ let source: String = span
+ .text
+ .iter()
+ .map(|l| l.text.as_str())
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let byte_range = if span.text.len() == 1 {
+ let line = &span.text[0];
+ let start = char_offset_to_byte_offset(&line.text, line.highlight_start);
+ let end = char_offset_to_byte_offset(&line.text, line.highlight_end);
+ start..end
+ } else {
+ let first = &span.text[0];
+ let last = span.text.last().unwrap();
+ let start = char_offset_to_byte_offset(&first.text, first.highlight_start);
+ let prefix_len: usize = span.text[..span.text.len() - 1]
+ .iter()
+ .map(|l| l.text.len() + 1)
+ .sum();
+ let end = prefix_len + char_offset_to_byte_offset(&last.text, last.highlight_end);
+ start..end
+ };
+
+ let mut snippet = Snippet::source(source)
+ .line_start(span.line_start)
+ .path(span.file_name.as_str());
+
+ if let Some(ref label) = span.label {
+ let annotation = AnnotationKind::Primary
+ .span(byte_range)
+ .label(label.as_str());
+ snippet = snippet.annotation(annotation);
+ } else {
+ let annotation = AnnotationKind::Primary.span(byte_range);
+ snippet = snippet.annotation(annotation);
+ }
+
+ group = group.element(snippet);
+ }
+
+ for child in &d.children {
+ let msg_level = cargo_level_to_annotate(child.level);
+ let msg = msg_level.clone().message(child.message.as_str());
+ group = group.element(msg);
+ }
+
+ vec![group]
+}
diff --git a/mingling_cli/src/errors.rs b/mingling_cli/src/errors.rs
new file mode 100644
index 0000000..cef9616
--- /dev/null
+++ b/mingling_cli/src/errors.rs
@@ -0,0 +1 @@
+pub mod serde_json;
diff --git a/mingling_cli/src/errors/serde_json.rs b/mingling_cli/src/errors/serde_json.rs
new file mode 100644
index 0000000..c73329c
--- /dev/null
+++ b/mingling_cli/src/errors/serde_json.rs
@@ -0,0 +1,8 @@
+use mingling::macros::{buffer, group, r_println, renderer};
+
+group!(ErrorSerdeJson = serde_json::Error);
+
+#[renderer(buffer)]
+pub fn render_error_serde_json(_err: ErrorSerdeJson) {
+ r_println!("serde");
+}
diff --git a/mingling_cli/src/linter.rs b/mingling_cli/src/linter.rs
new file mode 100644
index 0000000..c85afc1
--- /dev/null
+++ b/mingling_cli/src/linter.rs
@@ -0,0 +1,67 @@
+use mingling::{
+ Program,
+ macros::{chain, dispatcher, entry, program_setup},
+};
+
+use crate::{
+ linter::cmd_mlint::{CMDMinglingLinter, EntryMinglingLinter},
+ metadata::setup::ResUsingJson,
+};
+
+pub mod cmd_mlint;
+pub mod mlint_attr;
+pub mod mlint_report;
+
+#[program_setup]
+pub fn mingling_linter_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_setup(MinglingLinterCommandSetup);
+}
+
+#[program_setup]
+pub fn mingling_linter_command_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_dispatcher(CMDMinglingLinter);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzer);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzerWithClippy);
+ program.with_dispatcher(CMDLinterSupportRustAnalyzerWithCheck);
+}
+
+// Aliases
+
+dispatcher!("ra-lint-clippy",
+ CMDLinterSupportRustAnalyzerWithClippy => EntryLinterSupportRustAnalyzerWithClippy
+);
+
+dispatcher!("ra-lint-check",
+ CMDLinterSupportRustAnalyzerWithCheck => EntryLinterSupportRustAnalyzerWithCheck
+);
+
+dispatcher!("ra-lint",
+ CMDLinterSupportRustAnalyzer => EntryLinterSupportRustAnalyzer
+);
+
+#[chain]
+pub fn handle_ra_lint(
+ _: EntryLinterSupportRustAnalyzer,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ use_json.using = true;
+ entry!("--message-format=json")
+}
+
+#[chain]
+pub fn handle_ra_lint_check(
+ _: EntryLinterSupportRustAnalyzerWithCheck,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ use_json.using = true;
+ entry!("--message-format=json", "--with-checker=cargo,check")
+}
+
+#[chain]
+pub fn handle_ra_lint_clippy(
+ _: EntryLinterSupportRustAnalyzerWithClippy,
+ use_json: &mut ResUsingJson,
+) -> EntryMinglingLinter {
+ use_json.using = true;
+ entry!("--message-format=json", "--with-checker=cargo,clippy")
+}
diff --git a/mingling_cli/src/linter/cmd_mlint.rs b/mingling_cli/src/linter/cmd_mlint.rs
new file mode 100644
index 0000000..90871af
--- /dev/null
+++ b/mingling_cli/src/linter/cmd_mlint.rs
@@ -0,0 +1,119 @@
+use crate::linter::mlint_report::{MlintReport, StateLintReports};
+use cargo_metadata::Metadata;
+use mingling::LazyRes;
+use mingling::consts::REMAINS;
+use mingling::macros::{arg, chain, dispatcher, pack};
+use mingling::picker::EntryPicker;
+
+dispatcher!("lint", CMDMinglingLinter => EntryMinglingLinter);
+
+/// Main linting function that processes all packages in the metadata.
+///
+/// Iterates through all packages and their targets (e.g., binaries, libraries, tests),
+/// reads Rust source files (`.rs`), parses them into ASTs, runs lint checks,
+/// and enriches each report with metadata information.
+async fn linter_main(metadata: &Metadata) -> Vec<MlintReport> {
+ // Vector to accumulate all lint reports
+ let mut all_reports = Vec::new();
+
+ // Iterate over all packages in the metadata
+ for package in &metadata.packages {
+ // Iterate over all targets within a package (e.g., bin, lib, test, etc.)
+ for target in &package.targets {
+ let path = &target.src_path;
+ // Only process Rust source files (with `.rs` extension)
+ if !path.as_str().ends_with(".rs") {
+ continue;
+ }
+ // Read the source file content
+ let source = match std::fs::read_to_string(path.as_str()) {
+ Ok(s) => s,
+ Err(_) => continue,
+ };
+ // Parse the source file into an AST (Abstract Syntax Tree)
+ let ast = match syn::parse_file(&source) {
+ Ok(f) => f,
+ Err(_) => continue,
+ };
+
+ // Run all lint checks and collect reports
+ let reports = crate::lints::run_all_lints(&ast, &source);
+ //
+ for mut r in reports {
+ // Enrich report with metadata information
+ r.file_name = path.as_str().to_string();
+ r.source_code = source.clone();
+ r.package_id = Some(package.id.to_string());
+ r.target_name = Some(target.name.clone());
+ r.target_kind = target.kind.first().map(|k| k.to_string());
+ r.target_src_path = Some(path.as_str().to_string());
+ all_reports.push(r);
+ }
+ }
+ }
+
+ all_reports
+}
+
+pack!(StateBeginLinter = ());
+
+#[chain]
+pub fn handle_lint(args: EntryMinglingLinter) -> StateBeginLinter {
+ let (with_checker, checker_args) = args
+ .pick_or(&arg![with_checker: Option<String>], || {
+ Some("cargo,check".to_string())
+ })
+ .pick(&REMAINS)
+ .unwrap();
+
+ // If with_checker is not set, proceed directly to the mingling lint phase
+ let Some(with_checker) = with_checker else {
+ return StateBeginLinter::new(());
+ };
+
+ let with_checker: Vec<&str> = with_checker.split(',').collect();
+ let checker_args: Vec<String> = checker_args.into();
+
+ // Run the outer checker (e.g. cargo check) with output passed through directly
+ execute_checker(&with_checker, checker_args.as_slice());
+
+ StateBeginLinter::new(())
+}
+
+/// Run the outer checker (e.g. cargo check) with output passed through directly.
+fn execute_checker(with_checker: &[&str], checker_args: &[String]) {
+ if with_checker.is_empty() {
+ return;
+ }
+
+ let checker_str = with_checker.join(" ");
+ let args_str = checker_args.join(" ");
+ let full_cmd = if args_str.is_empty() {
+ checker_str
+ } else {
+ format!("{} {}", checker_str, args_str)
+ };
+
+ let mut cmd = if cfg!(target_os = "windows") {
+ let mut c = std::process::Command::new("cmd");
+ c.args(["/C", &full_cmd]);
+ c
+ } else {
+ let mut c = std::process::Command::new("sh");
+ c.args(["-c", &full_cmd]);
+ c
+ };
+
+ // Pass through stdin/stdout/stderr so the user sees everything
+ let _ = cmd.status();
+}
+
+#[chain]
+pub async fn handle_state_begin_linter(
+ _: StateBeginLinter,
+ metadata: &mut LazyRes<crate::metadata::setup::ResMetadata>,
+) -> StateLintReports {
+ let metadata = metadata.get_ref().data();
+ let reports = linter_main(metadata).await;
+ StateLintReports::new(reports)
+}
diff --git a/mingling_cli/src/linter/mlint_attr.rs b/mingling_cli/src/linter/mlint_attr.rs
new file mode 100644
index 0000000..24528d4
--- /dev/null
+++ b/mingling_cli/src/linter/mlint_attr.rs
@@ -0,0 +1,39 @@
+/// Result of checking `mlint(allow/warn/deny(...))` attributes.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum MlintLevelOverride {
+ Allow,
+ Warn,
+ Deny,
+}
+
+/// Parse `mlint(allow/warn/deny(lint_name))` from attributes.
+/// Returns `None` if the lint is not mentioned in any mlint attribute.
+pub fn get_mlint_override(attrs: &[syn::Attribute], lint_name: &str) -> Option<MlintLevelOverride> {
+ for attr in attrs {
+ if !attr.path().is_ident("mlint") {
+ continue;
+ }
+ let list = match attr.meta.require_list() {
+ Ok(l) => l,
+ Err(_) => continue,
+ };
+ // TokenStream::to_string() adds spaces between tokens,
+ // e.g. `allow(xxx)` → `allow ( xxx )`. Remove all spaces to compare.
+ let flat: String = list
+ .tokens
+ .to_string()
+ .chars()
+ .filter(|c| !c.is_whitespace())
+ .collect();
+ for (keyword, variant) in [
+ ("allow", MlintLevelOverride::Allow),
+ ("warn", MlintLevelOverride::Warn),
+ ("deny", MlintLevelOverride::Deny),
+ ] {
+ if flat.contains(&format!("{keyword}({lint_name})")) {
+ return Some(variant);
+ }
+ }
+ }
+ None
+}
diff --git a/mingling_cli/src/linter/mlint_report.rs b/mingling_cli/src/linter/mlint_report.rs
new file mode 100644
index 0000000..0472056
--- /dev/null
+++ b/mingling_cli/src/linter/mlint_report.rs
@@ -0,0 +1,445 @@
+use std::ops::Range;
+
+use cargo_metadata::diagnostic::{
+ DiagnosticCodeBuilder, DiagnosticLevel as CargoLevel, DiagnosticSpanBuilder,
+ DiagnosticSpanLineBuilder,
+};
+
+use cargo_metadata::{Message, PackageId};
+
+use annotate_snippets::level::{ERROR, HELP, NOTE, WARNING};
+use annotate_snippets::{AnnotationKind, Group, Patch, Renderer, Snippet};
+use mingling::macros::{buffer, chain, pack, r_append, r_eprintln, renderer};
+use mingling::{AnyOutput, ProgramCollect, Routable};
+
+use crate::Next;
+use crate::metadata::setup::ResUsingJson;
+
+/// Complete structure of a Lint report, containing inspection results and associated metadata.
+#[derive(Default)]
+pub struct MlintReport {
+ /// Source file name
+ pub file_name: String,
+
+ /// Full source text of the file, used to extract line content and compute byte offsets
+ pub source_code: String,
+
+ /// Severity level of the report
+ pub level: MlintLevel,
+
+ /// Name of the Lint
+ pub lint_code: String,
+
+ /// Content of the report
+ pub message: String,
+
+ /// Source code locations
+ pub spans: Vec<LintSpan>,
+
+ /// Attached sub-reports for this report
+ pub attached_reports: Vec<MlintReport>,
+
+ /// Package ID that this report belongs to
+ pub package_id: Option<String>,
+
+ /// Compilation target name that this report belongs to
+ pub target_name: Option<String>,
+
+ /// Compilation target type that this report belongs to
+ pub target_kind: Option<String>,
+
+ /// Compilation target source file path that this report belongs to
+ pub target_src_path: Option<String>,
+
+ /// Suggestions for automatic fix (shown as diff in annotated output)
+ pub suggestions: Vec<LintSuggestion>,
+}
+
+/// Report severity level, indicating the seriousness of the Lint result.
+#[derive(Default, Clone, Copy, PartialEq, Eq)]
+pub enum MlintLevel {
+ #[default]
+ Note,
+ Error,
+ Warning,
+ Help,
+}
+
+/// Source code location span, representing a range of source code and its associated text information.
+pub struct LintSpan {
+ /// Starting line number (1-based)
+ pub line_start: usize,
+ /// Ending line number (1-based)
+ pub line_end: usize,
+ /// Starting column (1-based char offset)
+ pub column_start: usize,
+ /// Ending column (1-based char offset)
+ pub column_end: usize,
+ /// Source lines at this location
+ pub text: Vec<LintSpanLine>,
+ /// Optional label description
+ pub label: Option<String>,
+}
+
+/// A single line of text in a source location with highlight range.
+pub struct LintSpanLine {
+ /// Full source line text (no trailing `\n`)
+ pub text: String,
+ /// Highlight start (1-based char offset)
+ pub highlight_start: usize,
+ /// Highlight end (1-based char offset)
+ pub highlight_end: usize,
+}
+
+/// A suggestion shown as a diff in the output (e.g. `- old code` / `+ new code`).
+#[derive(Clone, Debug, Default)]
+pub struct LintSuggestion {
+ /// Source text that the suggestion applies to (a single line or snippet)
+ pub source: String,
+ /// Line number where the suggestion applies
+ pub line_start: usize,
+ /// Byte range within `source` to replace
+ pub byte_range: Range<usize>,
+ /// Replacement text
+ pub replacement: String,
+}
+
+impl MlintReport {
+ /// Build a `LintSpan` from a syn spanned item and the source text.
+ pub fn span_from_syn<T: syn::spanned::Spanned>(value: &T, source: &str) -> LintSpan {
+ let span = value.span();
+ let start = span.start();
+ let end = span.end();
+
+ // Extract line content from source
+ let lines: Vec<&str> = source.lines().collect();
+ let text = if start.line == end.line && start.line <= lines.len() {
+ let line_text = lines[start.line.saturating_sub(1)];
+ let hl_start = proc_macro2_byte_col_to_char_1based(line_text, start.column);
+ let hl_end = proc_macro2_byte_col_to_char_1based(line_text, end.column);
+ vec![LintSpanLine {
+ text: line_text.to_string(),
+ highlight_start: hl_start,
+ highlight_end: hl_end,
+ }]
+ } else {
+ // Multi-line: generate line by line
+ (start.line..=end.line.min(lines.len()))
+ .map(|i| {
+ let line_text = lines[i.saturating_sub(1)];
+ let (hl_start, hl_end) = if i == start.line {
+ (
+ proc_macro2_byte_col_to_char_1based(line_text, start.column),
+ line_text.chars().count(),
+ )
+ } else if i == end.line {
+ (
+ 1,
+ proc_macro2_byte_col_to_char_1based(line_text, end.column),
+ )
+ } else {
+ (1, line_text.chars().count())
+ };
+ LintSpanLine {
+ text: line_text.to_string(),
+ highlight_start: hl_start,
+ highlight_end: hl_end,
+ }
+ })
+ .collect::<Vec<_>>()
+ };
+
+ LintSpan {
+ line_start: start.line,
+ line_end: end.line,
+ column_start: proc_macro2_byte_col_to_char_1based(
+ lines.get(start.line.saturating_sub(1)).unwrap_or(&""),
+ start.column,
+ ),
+ column_end: proc_macro2_byte_col_to_char_1based(
+ lines.get(end.line.saturating_sub(1)).unwrap_or(&""),
+ end.column,
+ ),
+ text,
+ label: None,
+ }
+ }
+
+ /// Compute byte offset from (line, column) within source.
+ /// line: 1-based, column: 1-based char offset.
+ pub fn line_col_to_byte_offset(&self, line: usize, col: usize) -> usize {
+ let mut byte_pos = 0usize;
+ for (i, line_str) in self.source_code.lines().enumerate() {
+ if i + 1 == line {
+ return byte_pos + char_1based_to_byte_offset(line_str, col);
+ }
+ byte_pos += line_str.len() + 1; // +1 for \n
+ }
+ self.source_code.len()
+ }
+}
+
+/// proc-macro2's LineColumn.column is **0-based byte offset**.
+/// Convert to 1-based char offset.
+fn proc_macro2_byte_col_to_char_1based(line: &str, byte_col: usize) -> usize {
+ line.char_indices()
+ .position(|(i, _)| i >= byte_col)
+ .map(|pos| pos + 1) // → 1-based
+ .unwrap_or(line.chars().count().max(1))
+}
+
+/// 1-based char offset → byte offset within a string
+fn char_1based_to_byte_offset(s: &str, char_1based: usize) -> usize {
+ s.char_indices()
+ .nth(char_1based.saturating_sub(1))
+ .map(|(i, _)| i)
+ .unwrap_or(s.len())
+}
+
+impl MlintReport {
+ pub fn to_annotate_snippet_render(&self) -> String {
+ let level = mlinit_level_to_annotate(&self.level);
+ let title = level.clone().primary_title(&self.message);
+ // code 不放在 title 里,改放在 note 中
+ let mut group = Group::with_title(title);
+
+ for span in &self.spans {
+ let source = span
+ .text
+ .iter()
+ .map(|l| l.text.as_str())
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ let byte_range = if span.text.len() == 1 {
+ let line = &span.text[0];
+ let start = char_1based_to_byte_offset(&line.text, line.highlight_start);
+ let end = char_1based_to_byte_offset(&line.text, line.highlight_end);
+ start..end
+ } else {
+ let first = &span.text[0];
+ let last = span.text.last().unwrap();
+ let start = char_1based_to_byte_offset(&first.text, first.highlight_start);
+ let prefix_len: usize = span.text[..span.text.len() - 1]
+ .iter()
+ .map(|l| l.text.len() + 1)
+ .sum();
+ let end = prefix_len + char_1based_to_byte_offset(&last.text, last.highlight_end);
+ start..end
+ };
+
+ let mut snippet = Snippet::source(source)
+ .line_start(span.line_start)
+ .path(&self.file_name);
+
+ let annotation = match &span.label {
+ Some(label) => AnnotationKind::Primary
+ .span(byte_range)
+ .label(label.as_str()),
+ None => AnnotationKind::Primary.span(byte_range),
+ };
+ snippet = snippet.annotation(annotation);
+ group = group.element(snippet);
+ }
+
+ for child in &self.attached_reports {
+ let child_level = mlinit_level_to_annotate(&child.level);
+ let msg = child_level.clone().message(&child.message);
+ group = group.element(msg);
+ }
+
+ // Render suggestions as diffs
+ for sugg in &self.suggestions {
+ let patch_snippet = Snippet::source(sugg.source.clone())
+ .line_start(sugg.line_start)
+ .path(&self.file_name)
+ .patch(Patch::new(
+ sugg.byte_range.clone(),
+ sugg.replacement.clone(),
+ ));
+ group = group.element(patch_snippet);
+ }
+
+ if !self.lint_code.is_empty() {
+ let level_name = match self.level {
+ MlintLevel::Error => "deny",
+ MlintLevel::Warning => "warn",
+ MlintLevel::Note | MlintLevel::Help => "allow",
+ };
+ let note_text = format!("`#[mlint({level_name}({}))]` on by default", self.lint_code);
+ let note_msg = NOTE.clone().message(note_text);
+ group = group.element(note_msg);
+ }
+
+ let renderer = Renderer::styled();
+ renderer.render(&[group])
+ }
+}
+
+fn mlinit_level_to_annotate(level: &MlintLevel) -> &'static annotate_snippets::Level<'static> {
+ match level {
+ MlintLevel::Error => &ERROR,
+ MlintLevel::Warning => &WARNING,
+ MlintLevel::Note => &NOTE,
+ MlintLevel::Help => &HELP,
+ }
+}
+
+impl MlintReport {
+ pub fn to_compiler_message(&self) -> Message {
+ use cargo_metadata::{CompilerMessageBuilder, Edition, TargetBuilder};
+
+ let target_kind_str = self.target_kind.as_deref().unwrap_or("bin");
+ let target_kind_parsed: cargo_metadata::TargetKind = target_kind_str.into();
+ let crate_kind: cargo_metadata::CrateType = target_kind_str.into();
+
+ let target = TargetBuilder::default()
+ .name(self.target_name.as_deref().unwrap_or_default())
+ .kind(vec![target_kind_parsed])
+ .crate_types(vec![crate_kind])
+ .required_features(Vec::<String>::new())
+ .src_path(self.target_src_path.as_deref().unwrap_or_default())
+ .edition(Edition::E2021)
+ .doctest(false)
+ .test(false)
+ .doc(false)
+ .build()
+ .unwrap();
+
+ let diagnostic = self.build_diagnostic(
+ &self.message,
+ &self.lint_code,
+ &self.level,
+ &self.spans,
+ &self.attached_reports,
+ );
+
+ Message::CompilerMessage(
+ CompilerMessageBuilder::default()
+ .package_id(PackageId {
+ repr: self.package_id.clone().unwrap_or_else(|| "unknown".into()),
+ })
+ .target(target)
+ .message(diagnostic)
+ .build()
+ .unwrap(),
+ )
+ }
+
+ fn build_diagnostic(
+ &self,
+ message: &str,
+ code: &str,
+ level: &MlintLevel,
+ spans: &[LintSpan],
+ children: &[MlintReport],
+ ) -> cargo_metadata::diagnostic::Diagnostic {
+ cargo_metadata::diagnostic::DiagnosticBuilder::default()
+ .message(message)
+ .code(
+ DiagnosticCodeBuilder::default()
+ .code(code)
+ .explanation(None)
+ .build()
+ .unwrap(),
+ )
+ .level(match level {
+ MlintLevel::Error => CargoLevel::Error,
+ MlintLevel::Warning => CargoLevel::Warning,
+ MlintLevel::Note => CargoLevel::Note,
+ MlintLevel::Help => CargoLevel::Help,
+ })
+ .spans(
+ spans
+ .iter()
+ .map(|s| self.lint_span_to_diagnostic_span(s))
+ .collect::<Vec<_>>(),
+ )
+ .children(
+ children
+ .iter()
+ .map(|c| {
+ self.build_diagnostic(
+ &c.message,
+ &c.lint_code,
+ &c.level,
+ &c.spans,
+ &c.attached_reports,
+ )
+ })
+ .collect::<Vec<_>>(),
+ )
+ .rendered(None)
+ .build()
+ .unwrap()
+ }
+
+ fn lint_span_to_diagnostic_span(
+ &self,
+ span: &LintSpan,
+ ) -> cargo_metadata::diagnostic::DiagnosticSpan {
+ let byte_start = self.line_col_to_byte_offset(span.line_start, span.column_start);
+ let byte_end = self.line_col_to_byte_offset(span.line_end, span.column_end);
+
+ DiagnosticSpanBuilder::default()
+ .file_name(&self.file_name)
+ .byte_start(byte_start as u32)
+ .byte_end(byte_end as u32)
+ .line_start(span.line_start)
+ .line_end(span.line_end)
+ .column_start(span.column_start)
+ .column_end(span.column_end)
+ .is_primary(true)
+ .text(
+ span.text
+ .iter()
+ .map(|l| {
+ DiagnosticSpanLineBuilder::default()
+ .text(&l.text)
+ .highlight_start(l.highlight_start)
+ .highlight_end(l.highlight_end)
+ .build()
+ .unwrap()
+ })
+ .collect::<Vec<_>>(),
+ )
+ .label(span.label.clone())
+ .suggested_replacement(self.suggestions.first().map(|s| s.replacement.clone()))
+ .suggestion_applicability(if !self.suggestions.is_empty() {
+ Some(cargo_metadata::diagnostic::Applicability::MachineApplicable)
+ } else {
+ None
+ })
+ .expansion(None)
+ .build()
+ .unwrap()
+ }
+}
+
+pack!(StateLintReports = Vec<MlintReport>);
+pack!(ResultLintReportsAnnotateSnippet = Vec<MlintReport>);
+pack!(ResultLintReportsJson = Vec<MlintReport>);
+
+#[chain]
+pub fn handle_state_lint_reports(reports: StateLintReports, using_json: &ResUsingJson) -> Next {
+ if using_json.using {
+ ResultLintReportsJson::new(reports.inner).to_render()
+ } else {
+ ResultLintReportsAnnotateSnippet::new(reports.inner).to_render()
+ }
+}
+
+#[renderer(buffer)]
+pub fn render_lint_reports(reports: ResultLintReportsAnnotateSnippet) {
+ for report in reports.inner {
+ r_eprintln!("{}", report.to_annotate_snippet_render());
+ }
+}
+
+#[renderer(buffer)]
+pub fn render_lint_reports_json(reports: ResultLintReportsJson) {
+ for report in reports.inner {
+ // DIRTY: Dispatch to the Message renderer using AnyOutput to obtain the render result and append it to the Buffer
+ r_append!(|| { crate::ThisProgram::render(AnyOutput::new(report.to_compiler_message())) });
+ }
+}
diff --git a/mingling_cli/src/lints.rs b/mingling_cli/src/lints.rs
new file mode 100644
index 0000000..89f5ca3
--- /dev/null
+++ b/mingling_cli/src/lints.rs
@@ -0,0 +1,82 @@
+#![allow(unused)]
+use crate::linter::mlint_report::{MlintLevel, MlintReport};
+
+mod non_mingling_naming_style;
+pub use non_mingling_naming_style::linter as non_mingling_naming_style;
+mod template_linter;
+pub use template_linter::linter as template_linter;
+mod unnecessary_render_result_creation;
+pub use unnecessary_render_result_creation::linter as unnecessary_render_result_creation;
+pub use non_mingling_naming_style::check_file as non_mingling_naming_style_file;
+
+/// Run all registered lints on a parsed file with its source text.
+pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> {
+ use crate::linter::mlint_attr::{get_mlint_override, MlintLevelOverride};
+
+ // File-level lints (check types, modules, etc.)
+ let mut reports: Vec<MlintReport> = vec![];
+ reports.extend(non_mingling_naming_style::check_file(file, source));
+
+ // Item-level lints (check each function)
+ for item in &file.items {
+ if let syn::Item::Fn(f) = item {
+ let skip = get_mlint_override(&f.attrs, "non_mingling_naming_style") == Some(MlintLevelOverride::Allow);
+ if !skip {
+ let mut rs = non_mingling_naming_style::linter(f.clone(), source);
+ if get_mlint_override(&f.attrs, "non_mingling_naming_style") == Some(MlintLevelOverride::Deny) {
+ for r in &mut rs { r.level = MlintLevel::Error; }
+ }
+ reports.extend(rs);
+ }
+ let skip = get_mlint_override(&f.attrs, "template_linter") == Some(MlintLevelOverride::Allow);
+ if !skip {
+ let mut rs = template_linter::linter(f.clone(), source);
+ if get_mlint_override(&f.attrs, "template_linter") == Some(MlintLevelOverride::Deny) {
+ for r in &mut rs { r.level = MlintLevel::Error; }
+ }
+ reports.extend(rs);
+ }
+ let skip = get_mlint_override(&f.attrs, "unnecessary_render_result_creation") == Some(MlintLevelOverride::Allow);
+ if !skip {
+ let mut rs = unnecessary_render_result_creation::linter(f.clone(), source);
+ if get_mlint_override(&f.attrs, "unnecessary_render_result_creation") == Some(MlintLevelOverride::Deny) {
+ for r in &mut rs { r.level = MlintLevel::Error; }
+ }
+ reports.extend(rs);
+ }
+ }
+ }
+
+ // Apply file-level #![mlint(allow/warn/deny(...))] overrides
+ for r in &mut reports {
+ if let Some(override_level) = get_mlint_override(&file.attrs, &r.lint_code) {
+ match override_level {
+ MlintLevelOverride::Allow => r.level = MlintLevel::Help,
+ MlintLevelOverride::Deny => r.level = MlintLevel::Error,
+ MlintLevelOverride::Warn => {
+ if r.level != MlintLevel::Error { r.level = MlintLevel::Warning; }
+ }
+ }
+ }
+ }
+ reports.retain(|r| r.level != MlintLevel::Help);
+ reports
+}
+
+#[macro_export]
+macro_rules! assert_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!(!$linter(ast, &source).is_empty());
+ };
+}
+
+#[macro_export]
+macro_rules! assert_not_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!($linter(ast, &source).is_empty());
+ };
+} \ No newline at end of file
diff --git a/mingling_cli/src/lints/non_mingling_naming_style.rs b/mingling_cli/src/lints/non_mingling_naming_style.rs
new file mode 100644
index 0000000..83168b7
--- /dev/null
+++ b/mingling_cli/src/lints/non_mingling_naming_style.rs
@@ -0,0 +1,352 @@
+//! Non-Mingling Naming Style
+//!
+//! ## Summary
+//!
+//! Checks that Mingling functions follow naming conventions:
+//!
+//! | Prefix | 1st param must be |
+//! |--------|------------------|
+//! | `handle_` | `Entry*` |
+//! | `handle_state_` | `State*` |
+//! | `handle_error_` | `Error*` |
+//! | `help_` | `Entry*` |
+//! | `render_` | `Result*` |
+//! | `render_error_` | `Error*` |
+//!
+//! The name after prefix (snake_case) must match the type after prefix (PascalCase).
+//!
+//! ## Metadata
+//!
+//! Author: `Weicao-CatilGrass`
+//! Default: `warn`
+
+use crate::linter::mlint_report::{LintSuggestion, MlintLevel, MlintReport};
+use quote::ToTokens;
+use syn::spanned::Spanned;
+
+/// File-level entry (placeholder).
+pub fn check_file(_file: &syn::File, _source: &str) -> Vec<MlintReport> {
+ vec![]
+}
+
+/// ItemFn entry.
+pub fn linter(ast: syn::ItemFn, source: &str) -> Vec<MlintReport> {
+ check_fn_name(&ast, source)
+}
+
+fn check_fn_name(func: &syn::ItemFn, source: &str) -> Vec<MlintReport> {
+ // Only check functions with Mingling attributes
+ let has_mingling_attr = func.attrs.iter().any(|a| {
+ let name = a.path().to_token_stream().to_string();
+ name.ends_with("renderer")
+ || name.ends_with("chain")
+ || name.ends_with("help")
+ || name.ends_with("completion")
+ });
+ if !has_mingling_attr {
+ return vec![];
+ }
+
+ let name = func.sig.ident.to_string();
+ let first_param = func.sig.inputs.first();
+
+ let rule: Option<(&str, &str)> = {
+ if name.starts_with("handle_state_") {
+ Some(("handle_state_", "State"))
+ } else if name.starts_with("handle_error_") {
+ Some(("handle_error_", "Error"))
+ } else if name.starts_with("handle_") {
+ Some(("handle_", "Entry"))
+ } else if name.starts_with("render_error_") {
+ Some(("render_error_", "Error"))
+ } else if name.starts_with("render_") {
+ Some(("render_", "Result"))
+ } else if name.starts_with("help_") {
+ Some(("help_", "Entry"))
+ } else {
+ None
+ }
+ };
+
+ let Some((prefix, expected_prefix)) = rule else {
+ return vec![];
+ };
+ let fn_rest = &name[prefix.len()..];
+
+ let Some(first) = first_param else {
+ return vec![MlintReport {
+ level: MlintLevel::Warning,
+ lint_code: "non_mingling_naming_style".into(),
+ message: format!(
+ "`{name}` should take `{expected_prefix}*` as its first parameter, but it has no parameters"
+ ),
+ ..Default::default()
+ }];
+ };
+
+ let type_name = param_type_name(first);
+
+ if !type_name.starts_with(expected_prefix) || type_name.len() <= expected_prefix.len() {
+ let expected_type = format!("{expected_prefix}{}", snake_to_pascal(fn_rest));
+ return vec![MlintReport {
+ level: MlintLevel::Warning,
+ lint_code: "non_mingling_naming_style".into(),
+ message: format!(
+ "`{name}` should take `{expected_prefix}*`, but got `{type_name}` — rename it to `{expected_type}`"
+ ),
+ spans: vec![MlintReport::span_from_syn(&func.sig.ident, source)],
+ ..Default::default()
+ }];
+ }
+
+ let type_rest = &type_name[expected_prefix.len()..];
+ let fn_rest_normalized = snake_to_pascal(fn_rest);
+
+ if type_rest != fn_rest_normalized {
+ let expected_type = format!("{expected_prefix}{fn_rest_normalized}");
+ let expected_fn = format!("{prefix}{}", pascal_to_snake(type_rest));
+
+ // Heuristic: if the type's base name is a substring of fn_rest, the type is clean → rename fn
+ let rename_fn = fn_rest.to_lowercase().contains(&type_rest.to_lowercase())
+ && fn_rest.to_lowercase() != type_rest.to_lowercase();
+
+ let (msg, span) = if rename_fn {
+ (
+ format!(
+ "naming mismatch: rename `{name}` to `{expected_fn}` to match type `{type_name}`"
+ ),
+ MlintReport::span_from_syn(&func.sig.ident, source),
+ )
+ } else {
+ (
+ format!(
+ "naming mismatch: rename type `{type_name}` to `{expected_type}` to match function `{name}`"
+ ),
+ first_param_type_span(first, source),
+ )
+ };
+
+ // Build diff suggestion
+ let source_line = source
+ .lines()
+ .nth(func.sig.span().start().line.saturating_sub(1))
+ .unwrap_or("");
+ let (byte_range_start, suggestion_target) = if rename_fn {
+ // Find the old function name in the source line
+ let pos = source_line.find(&name).unwrap_or(0);
+ (pos, expected_fn.clone())
+ } else {
+ // Find the old type name in the source line
+ let pos = source_line.find(&type_name).unwrap_or(0);
+ (pos, expected_type.clone())
+ };
+ let byte_range = byte_range_start
+ ..byte_range_start
+ + if rename_fn {
+ name.len()
+ } else {
+ type_name.len()
+ };
+
+ return vec![MlintReport {
+ level: MlintLevel::Warning,
+ lint_code: "non_mingling_naming_style".into(),
+ message: msg,
+ spans: vec![span],
+ suggestions: vec![LintSuggestion {
+ source: source_line.to_string(),
+ line_start: func.sig.span().start().line,
+ byte_range,
+ replacement: suggestion_target,
+ }],
+ attached_reports: vec![MlintReport {
+ level: MlintLevel::Help,
+ message: format!("expected `{expected_fn}` ↔ `{expected_type}`"),
+ ..Default::default()
+ }],
+ ..Default::default()
+ }];
+ }
+
+ vec![]
+}
+
+fn param_type_name(arg: &syn::FnArg) -> String {
+ if let syn::FnArg::Typed(pat) = arg
+ && let syn::Type::Path(ref tp) = *pat.ty.clone()
+ {
+ return tp
+ .path
+ .segments
+ .iter()
+ .map(|s| s.ident.to_string())
+ .collect::<Vec<_>>()
+ .join("::");
+ }
+ String::new()
+}
+
+fn first_param_type_span(arg: &syn::FnArg, source: &str) -> crate::linter::mlint_report::LintSpan {
+ if let syn::FnArg::Typed(pat) = arg {
+ return MlintReport::span_from_syn(&*pat.ty, source);
+ }
+ MlintReport::span_from_syn(arg, source)
+}
+
+fn snake_to_pascal(s: &str) -> String {
+ let mut r = String::new();
+ let mut cap = true;
+ for ch in s.chars() {
+ if ch == '_' {
+ cap = true;
+ } else if cap {
+ r.extend(ch.to_uppercase());
+ cap = false;
+ } else {
+ r.push(ch);
+ }
+ }
+ r
+}
+
+fn pascal_to_snake(s: &str) -> String {
+ let mut r = String::new();
+ for (i, ch) in s.char_indices() {
+ if ch.is_uppercase() && i != 0 {
+ r.push('_');
+ }
+ for lower in ch.to_lowercase() {
+ r.push(lower);
+ }
+ }
+ r
+}
+
+#[cfg(test)]
+mod lint_test {
+ use crate::{assert_detected, assert_not_detected};
+
+ #[test]
+ fn handle_entry() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_greet(args: EntryGreet) {}
+ });
+ }
+
+ #[test]
+ fn handle_state() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_state_processing(prev: StateProcessing) {}
+ });
+ }
+
+ #[test]
+ fn handle_error() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_error_not_found(err: ErrorNotFound) {}
+ });
+ }
+
+ #[test]
+ fn render_result() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer]
+ fn render_greeting(result: ResultGreeting) {}
+ });
+ }
+
+ #[test]
+ fn render_error() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer]
+ fn render_error_not_found(err: ErrorNotFound) {}
+ });
+ }
+
+ #[test]
+ fn help_entry() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::help]
+ fn help_greet(args: EntryGreet) {}
+ });
+ }
+
+ #[test]
+ fn handle_should_be_entry() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_greet(x: String) {}
+ });
+ }
+
+ #[test]
+ fn handle_state_should_be_state() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_state_processing(x: String) {}
+ });
+ }
+
+ #[test]
+ fn handle_error_should_be_error() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_error_not_found(x: String) {}
+ });
+ }
+
+ #[test]
+ fn render_should_be_result() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer]
+ fn render_greeting(x: EntryGreet) {}
+ });
+ }
+
+ #[test]
+ fn render_error_should_be_error() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer]
+ fn render_error_not_found(x: ResultGreeting) {}
+ });
+ }
+
+ #[test]
+ fn name_mismatch_rename_fn() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer]
+ fn render_result_greeting(_greeting: ResultGreeting) {}
+ });
+ }
+
+ #[test]
+ fn name_mismatch_rename_type() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_greet(args: EntryHello) {}
+ });
+ }
+
+ #[test]
+ fn handle_no_params() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::chain]
+ fn handle_greet() {}
+ });
+ }
+
+ #[test]
+ fn regular_fn_ok() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ fn do_something(x: i32) {}
+ });
+ }
+
+ #[test]
+ fn handle_no_params_and_no_attrs() {
+ assert_not_detected!(super::linter, syn::ItemFn => { fn handle_greet() {} });
+ }
+}
diff --git a/mingling_cli/src/lints/template_linter.rs b/mingling_cli/src/lints/template_linter.rs
new file mode 100644
index 0000000..4836e24
--- /dev/null
+++ b/mingling_cli/src/lints/template_linter.rs
@@ -0,0 +1,56 @@
+//! Template Linter
+//!
+//! ## Summary
+//!
+//! This is a template Linter that introduces how to add a Lint for Mingling.
+//! You can write an introduction for this Linter in the Summary section, for example:
+//!
+//! - Trigger conditions
+//! - Why is it necessary?
+//!
+//! ## Metadata
+//!
+//! > This section is the **Metadata** section, which needs to be filled in correctly.
+//! > These contents will eventually be compiled as the Linter's behavior.
+//!
+//! Author: `Your-Name`
+//! Default: `allow`
+// ^^^^^ Supported parameters: `warn`, `allow`, `deny`
+
+// --- ABOUT AUTO IDENTIFICATION RULES ---
+//
+// The compiler will treat code with the following structure as a Linter entry point:
+// |
+// --> your_linter_module.rs
+// |
+// | pub fn linter(ast: syn::ItemX) -> Vec<MlintReport> {
+// | /* ... */ ^^^^^^^^^^ Your linter scope
+// | }
+// |
+// = note: Please ensure your function is `pub`, named `linter`, and returns `Vec<MlintReport>`
+//
+// --- ABOUT AUTO IDENTIFICATION RULES ---
+
+use crate::linter::mlint_report::MlintReport;
+
+pub fn linter(_ast: syn::ItemFn, _source: &str) -> Vec<MlintReport> {
+ // ^^^^^^^^^^^ Supported parameters:
+ // | syn::File
+ // | syn::ItemImpl
+ // | syn::ItemStruct
+ // | syn::ItemEnum
+ // | syn::ItemTrait
+ // | syn::ItemFn
+ // | syn::ItemMacro
+ // | syn::ItemMod
+ // | syn::ItemUnion
+ vec![]
+}
+
+#[cfg(test)]
+mod lint_test {
+ use crate::{assert_detected, assert_not_detected};
+
+ #[test]
+ fn test() {}
+}
diff --git a/mingling_cli/src/lints/unnecessary_render_result_creation.rs b/mingling_cli/src/lints/unnecessary_render_result_creation.rs
new file mode 100644
index 0000000..1450c1a
--- /dev/null
+++ b/mingling_cli/src/lints/unnecessary_render_result_creation.rs
@@ -0,0 +1,490 @@
+//! Unnecessary Manual RenderResult Creation
+//!
+//! ## Summary
+//!
+//! Detects `#[renderer]` functions that manually create a `RenderResult` and
+//! manage it via `r_println!(r, ...)` style calls, when they could be simplified
+//! to `#[renderer(buffer)]` which handles the buffer automatically.
+//!
+//! This lint will not trigger if `r` is used outside of `r_println!`,
+//! `r_eprintln!`, `r_print`, `r_eprint`, or `r_append`.
+//!
+//! ## Metadata
+//!
+//! Author: `Weicao-CatilGrass`
+//! Default: `warn`
+
+use crate::linter::mlint_report::{LintSuggestion, MlintLevel, MlintReport};
+use quote::ToTokens;
+use syn::spanned::Spanned;
+
+pub fn linter(ast: syn::ItemFn, source: &str) -> Vec<MlintReport> {
+ if !has_renderer_attr(&ast) {
+ return vec![];
+ }
+
+ let stmts = &ast.block.stmts;
+ if stmts.len() < 2 {
+ return vec![];
+ }
+
+ let (r_ident, let_idx) = match find_render_result_new(stmts) {
+ Some(pair) => pair,
+ None => return vec![],
+ };
+
+ let r_name = r_ident.to_string();
+ let mut only_print_and_return = true;
+ let mut print_count = 0;
+
+ for stmt in &stmts[let_idx + 1..] {
+ if !check_stmt_usage(stmt, &r_name, &mut print_count) {
+ only_print_and_return = false;
+ break;
+ }
+ }
+
+ if only_print_and_return && print_count > 0 {
+ let span = MlintReport::span_from_syn(&ast.sig, source);
+ let mut suggestions = Vec::new();
+
+ // 1. Attribute change: #[renderer] → #[renderer(buffer)]
+ if let Some(sugg) = make_attr_suggestion(&ast, source) {
+ suggestions.push(sugg);
+ }
+
+ // 2. Remove -> RenderResult from function signature
+ if let Some(sugg) = make_return_type_suggestion(&ast, source) {
+ suggestions.push(sugg);
+ }
+
+ // 3. Remove let mut r = RenderResult::...
+ if let Some(sugg) = make_let_removal_suggestion(stmts, let_idx, source) {
+ suggestions.push(sugg);
+ }
+
+ // 4. Fix r_println!(r, ...) → r_println!(...) for all r_xxx macros
+ suggestions.extend(make_macro_arg_suggestions(stmts, &r_name, source));
+
+ // 5. Remove return 'r' expression
+ if let Some(sugg) = make_return_removal_suggestion(stmts, &r_name, source) {
+ suggestions.push(sugg);
+ }
+
+ vec![MlintReport {
+ source_code: source.to_string(),
+ level: MlintLevel::Warning,
+ lint_code: "unnecessary_render_result_creation".into(),
+ message: format!(
+ "unnecessary manual `RenderResult` creation in `{}`: use `#[renderer(buffer)]` instead",
+ ast.sig.ident,
+ ),
+ spans: vec![span],
+ suggestions,
+ attached_reports: vec![MlintReport {
+ level: MlintLevel::Help,
+ message: format!(
+ "change to `#[renderer(buffer)]` and use `r_println!(...)` without the `{}` parameter",
+ r_name,
+ ),
+ ..Default::default()
+ }],
+ ..Default::default()
+ }]
+ } else {
+ vec![]
+ }
+}
+
+fn has_renderer_attr(func: &syn::ItemFn) -> bool {
+ func.attrs.iter().any(|a| {
+ if !a.path().is_ident("renderer") {
+ return false;
+ }
+ if let Ok(list) = a.meta.require_list() {
+ let tokens = list.tokens.to_string();
+ if tokens.contains("buffer") {
+ return false;
+ }
+ }
+ true
+ })
+}
+
+fn find_render_result_new(stmts: &[syn::Stmt]) -> Option<(proc_macro2::Ident, usize)> {
+ for (i, stmt) in stmts.iter().enumerate() {
+ if let syn::Stmt::Local(local) = stmt
+ && let Some(init) = &local.init
+ && let syn::Pat::Ident(pat_id) = &local.pat
+ && pat_id.mutability.is_some()
+ && let syn::Expr::Call(call) = &*init.expr
+ && let syn::Expr::Path(expr_path) = call.func.as_ref()
+ {
+ let segs = &expr_path.path.segments;
+ let matches = match segs.len() {
+ 2 => {
+ segs[0].ident == "RenderResult"
+ && (segs[1].ident == "new" || segs[1].ident == "default")
+ }
+ 3 => {
+ segs[0].ident == "mingling"
+ && segs[1].ident == "RenderResult"
+ && (segs[2].ident == "new" || segs[2].ident == "default")
+ }
+ _ => false,
+ };
+ if matches {
+ return Some((pat_id.ident.clone(), i));
+ }
+ }
+
+ // Also handle `RenderResult::from(...)` and `mingling::RenderResult::from(...)`
+ if let syn::Stmt::Local(local) = stmt
+ && let Some(init) = &local.init
+ && let syn::Pat::Ident(pat_id) = &local.pat
+ && pat_id.mutability.is_some()
+ && let syn::Expr::Call(call) = &*init.expr
+ && let syn::Expr::Path(expr_path) = call.func.as_ref()
+ {
+ let segs = &expr_path.path.segments;
+ let matches = match segs.len() {
+ 2 => segs[0].ident == "RenderResult" && segs[1].ident == "from",
+ 3 => {
+ segs[0].ident == "mingling"
+ && segs[1].ident == "RenderResult"
+ && segs[2].ident == "from"
+ }
+ _ => false,
+ };
+ if matches {
+ return Some((pat_id.ident.clone(), i));
+ }
+ }
+ }
+ None
+}
+
+fn check_stmt_usage(stmt: &syn::Stmt, r_name: &str, print_count: &mut usize) -> bool {
+ // return r; → allowed
+ if let syn::Stmt::Expr(expr, _) = stmt
+ && let syn::Expr::Return(ret) = expr
+ && let Some(ret_expr) = &ret.expr
+ && let syn::Expr::Path(p) = ret_expr.as_ref()
+ && p.path.is_ident(r_name)
+ {
+ return true;
+ }
+
+ // r_println!(r, ...) → allowed
+ if let syn::Stmt::Macro(stmt_mac) = stmt {
+ let macro_name = stmt_mac
+ .mac
+ .path
+ .segments
+ .last()
+ .map(|s| s.ident.to_string())
+ .unwrap_or_default();
+ let is_r_macro = matches!(
+ macro_name.as_str(),
+ "r_println" | "r_eprintln" | "r_print" | "r_eprint" | "r_append"
+ );
+ if is_r_macro
+ && let Some(first_arg) = stmt_mac.mac.tokens.clone().into_iter().next()
+ && first_arg.to_string() == *r_name
+ {
+ *print_count += 1;
+ return true;
+ }
+ }
+
+ // Any other reference to r → not allowed
+ // Check the token stream for the variable name
+ let ts_string = stmt.to_token_stream().to_string();
+ if ts_string.contains(&format!("({r_name})"))
+ || ts_string.contains(&format!(" {r_name})"))
+ || ts_string.contains(&format!("(&mut {r_name})"))
+ || ts_string.contains(&format!("&{r_name}"))
+ || ts_string.contains(&format!("move {r_name}"))
+ || ts_string.contains(&format!(",{r_name},"))
+ {
+ return false;
+ }
+ true
+}
+
+/// Build suggestion: `#[renderer]` → `#[renderer(buffer)]`
+fn make_attr_suggestion(ast: &syn::ItemFn, source: &str) -> Option<LintSuggestion> {
+ let attr = ast.attrs.iter().find(|a| {
+ let name = a.path().to_token_stream().to_string();
+ name.ends_with("renderer")
+ })?;
+
+ let line_idx = attr.span().start().line.saturating_sub(1);
+ let line = source.lines().nth(line_idx)?;
+
+ // Replace `renderer]` with `renderer(buffer)]`
+ // This handles both `#[renderer]` and `#[::mingling::macros::renderer]`
+ let line_str = line;
+ let replacement = line_str.replacen("renderer]", "renderer(buffer)]", 1);
+
+ if replacement == line_str {
+ return None;
+ }
+
+ Some(LintSuggestion {
+ source: line_str.to_string(),
+ line_start: line_idx + 1,
+ byte_range: 0..line_str.len(),
+ replacement,
+ })
+}
+
+/// Build suggestion: remove ` -> RenderResult` from function signature
+fn make_return_type_suggestion(ast: &syn::ItemFn, source: &str) -> Option<LintSuggestion> {
+ let syn::ReturnType::Type(arrow, ret_type) = &ast.sig.output else {
+ return None;
+ };
+
+ let sig_line_idx = ast.sig.span().start().line.saturating_sub(1);
+ let line = source.lines().nth(sig_line_idx)?;
+
+ // proc-macro2 column is 0-based byte offset from line start
+ let arrow_byte_col = arrow.span().start().column;
+ let ret_end_byte_col = ret_type.span().end().column;
+
+ // Include the space before `->`
+ let range_start = if arrow_byte_col > 0 {
+ arrow_byte_col - 1
+ } else {
+ arrow_byte_col
+ };
+
+ Some(LintSuggestion {
+ source: line.to_string(),
+ line_start: sig_line_idx + 1,
+ byte_range: range_start..ret_end_byte_col,
+ replacement: String::new(),
+ })
+}
+
+/// Build suggestion: remove `let mut r = RenderResult::...` line
+fn make_let_removal_suggestion(
+ stmts: &[syn::Stmt],
+ let_idx: usize,
+ source: &str,
+) -> Option<LintSuggestion> {
+ let stmt = &stmts[let_idx];
+ let line_idx = stmt.span().start().line.saturating_sub(1);
+ let line = source.lines().nth(line_idx)?;
+
+ Some(LintSuggestion {
+ source: line.to_string(),
+ line_start: line_idx + 1,
+ byte_range: 0..line.len(),
+ replacement: String::new(),
+ })
+}
+
+/// Build suggestions: fix `r_println!(r, ...)` → `r_println!(...)` for all r_xxx macros
+fn make_macro_arg_suggestions(
+ stmts: &[syn::Stmt],
+ r_name: &str,
+ source: &str,
+) -> Vec<LintSuggestion> {
+ let r_macros = ["r_println", "r_eprintln", "r_print", "r_eprint"];
+
+ stmts
+ .iter()
+ .filter_map(|stmt| {
+ let syn::Stmt::Macro(stmt_mac) = stmt else {
+ return None;
+ };
+
+ let macro_name = stmt_mac
+ .mac
+ .path
+ .segments
+ .last()
+ .map(|s| s.ident.to_string())
+ .unwrap_or_default();
+
+ if !r_macros.contains(&macro_name.as_str()) {
+ return None;
+ }
+
+ // Check that the first token is the r_name
+ let first_token = stmt_mac.mac.tokens.clone().into_iter().next()?;
+ if first_token.to_string() != *r_name {
+ return None;
+ }
+
+ let line_idx = stmt.span().start().line.saturating_sub(1);
+ let line = source.lines().nth(line_idx)?;
+
+ // Find pattern: macro_name!(r_name, ...
+ let macro_str = format!("{}!(", macro_name);
+ let macro_pos = line.find(&macro_str)?;
+ let after_open = macro_pos + macro_str.len();
+
+ // The first argument is `r_name` followed by `,` and possibly a space
+ // We need to find and remove `r_name, ` or `r_name,`
+ let first_arg = r_name;
+ if line[after_open..].starts_with(first_arg) {
+ // Find the end of the first argument (including `,` and any whitespace)
+ let arg_end = after_open + first_arg.len();
+ if arg_end < line.len() {
+ let rest = &line[arg_end..];
+ // Skip `,` and optional whitespace
+ let skip = if rest.starts_with(", ") {
+ 2
+ } else if rest.starts_with(',') {
+ 1
+ } else {
+ // Not followed by comma — not our pattern
+ return None;
+ };
+ let range_end = arg_end + skip;
+
+ Some(LintSuggestion {
+ source: line.to_string(),
+ line_start: line_idx + 1,
+ byte_range: after_open..range_end,
+ replacement: String::new(),
+ })
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ })
+ .collect()
+}
+
+/// Build suggestion: remove the return `r` expression
+fn make_return_removal_suggestion(
+ stmts: &[syn::Stmt],
+ r_name: &str,
+ source: &str,
+) -> Option<LintSuggestion> {
+ let last = stmts.last()?;
+
+ let is_r_return = match last {
+ // `r` (bare expression, no semicolon) or `r;` (with semicolon)
+ syn::Stmt::Expr(expr, _) => {
+ if let syn::Expr::Path(p) = expr {
+ p.path.is_ident(r_name)
+ } else if let syn::Expr::Return(ret) = expr {
+ ret.expr.as_ref().is_some_and(|e| {
+ if let syn::Expr::Path(p) = e.as_ref() {
+ p.path.is_ident(r_name)
+ } else {
+ false
+ }
+ })
+ } else {
+ false
+ }
+ }
+ _ => false,
+ };
+
+ if !is_r_return {
+ return None;
+ }
+
+ let line_idx = last.span().start().line.saturating_sub(1);
+ let line = source.lines().nth(line_idx)?;
+
+ Some(LintSuggestion {
+ source: line.to_string(),
+ line_start: line_idx + 1,
+ byte_range: 0..line.len(),
+ replacement: String::new(),
+ })
+}
+
+#[cfg(test)]
+mod lint_test {
+ use crate::{assert_detected, assert_not_detected};
+
+ #[test]
+ fn test_detected_render_result_new() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_detected_render_result_default() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::default();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_detected_render_result_from() {
+ assert_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::from("Hello".to_string());
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_other_function_call() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[renderer]
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ other(&mut r);
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_without_renderer_attr() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ fn render_somesthing(_: Prev) -> RenderResult {
+ let mut r = RenderResult::new();
+ r_println!(r, "");
+ r
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_buffer_attr() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer(::mingling::macros::buffer)]
+ fn render_somesthing(_: Prev) {
+ r_println!("");
+ }
+ });
+ }
+
+ #[test]
+ fn test_not_detected_with_buffer_attr_fully_qualified() {
+ assert_not_detected!(super::linter, syn::ItemFn => {
+ #[::mingling::macros::renderer(::mingling::macros::buffer)]
+ fn render_somesthing(_: Prev) {
+ r_println!("");
+ }
+ });
+ }
+}
diff --git a/mingling_cli/src/main.rs b/mingling_cli/src/main.rs
new file mode 100644
index 0000000..c822a12
--- /dev/null
+++ b/mingling_cli/src/main.rs
@@ -0,0 +1,29 @@
+use crate::{linter::MinglingLinterSetup, metadata::MinglingMetadataSetup};
+use mingling::{
+ macros::gen_program,
+ setup::{ExitCodeSetup, picker::HelpFlagSetup},
+};
+
+pub mod diagnostic;
+pub mod errors;
+pub mod linter;
+pub mod lints;
+pub mod message;
+pub mod metadata;
+
+#[tokio::main]
+async fn main() {
+ let mut program = ThisProgram::new();
+
+ // Setups
+ program.with_setup(HelpFlagSetup::default());
+ program.with_setup(ExitCodeSetup::default());
+
+ program.with_setup(MinglingMetadataSetup);
+ program.with_setup(MinglingLinterSetup);
+
+ // Exec
+ program.exec_and_exit().await;
+}
+
+gen_program!();
diff --git a/mingling_cli/src/message.rs b/mingling_cli/src/message.rs
new file mode 100644
index 0000000..3e50c30
--- /dev/null
+++ b/mingling_cli/src/message.rs
@@ -0,0 +1,10 @@
+use cargo_metadata::Message;
+use mingling::macros::{buffer, group, r_println, renderer, renderify};
+
+group!(Message);
+
+#[renderer(buffer, renderify)]
+pub fn render_message(msg: Message) {
+ let r = serde_json::to_string(&msg)?;
+ r_println!("{}", r);
+}
diff --git a/mingling_cli/src/metadata.rs b/mingling_cli/src/metadata.rs
new file mode 100644
index 0000000..2e7aeef
--- /dev/null
+++ b/mingling_cli/src/metadata.rs
@@ -0,0 +1,24 @@
+use cargo_metadata::Metadata;
+use mingling::{
+ Program,
+ macros::{buffer, group, program_setup, r_println, renderer, renderify},
+};
+
+use crate::metadata::{cmd_metadata::CMDMetadata, setup::CargoMetadataSetup};
+
+pub mod cmd_metadata;
+pub mod setup;
+
+#[program_setup]
+pub fn mingling_metadata_setup(program: &mut Program<crate::ThisProgram>) {
+ program.with_setup(CargoMetadataSetup);
+ program.with_dispatcher(CMDMetadata);
+}
+
+group!(Metadata);
+
+#[renderer(buffer, renderify)]
+pub fn render_metadata(metadata: Metadata) {
+ let result = serde_json::to_string(&metadata)?;
+ r_println!("{}", result);
+}
diff --git a/mingling_cli/src/metadata/cmd_metadata.rs b/mingling_cli/src/metadata/cmd_metadata.rs
new file mode 100644
index 0000000..18241a2
--- /dev/null
+++ b/mingling_cli/src/metadata/cmd_metadata.rs
@@ -0,0 +1,17 @@
+use cargo_metadata::Metadata;
+use mingling::{
+ LazyRes,
+ macros::{chain, dispatcher, pack},
+};
+
+use crate::metadata::setup::ResMetadata;
+
+dispatcher!("metadata");
+
+pack!(ResultMetadata = ResMetadata);
+
+#[chain]
+pub fn handle_metadata(_: EntryMetadata, metadata: &mut LazyRes<ResMetadata>) -> Metadata {
+ let metadata = metadata.get_ref().clone();
+ metadata.data().clone()
+}
diff --git a/mingling_cli/src/metadata/setup.rs b/mingling_cli/src/metadata/setup.rs
new file mode 100644
index 0000000..198baf1
--- /dev/null
+++ b/mingling_cli/src/metadata/setup.rs
@@ -0,0 +1,136 @@
+use std::{env::current_dir, path::PathBuf};
+
+use cargo_metadata::{CargoOpt, MetadataCommand};
+use mingling::{
+ LazyInit, Program,
+ macros::program_setup,
+ picker::{IntoPicker, value::Flag},
+ prelude::*,
+ res::ResCurrentDir,
+};
+
+/// Name of Cargo manifest file
+pub const CARGO_TOML: &str = "Cargo.toml";
+
+use cargo_metadata::Metadata;
+use serde::Serialize;
+
+/// Resource holding parsed `cargo metadata` output.
+///
+/// This is lazily initialized during program setup by calling `cargo metadata`
+/// with the appropriate CLI flags (e.g., `--features`, `--all-features`,
+/// `--no-default-features`, `--no-deps`, `--message-format`).
+///
+/// Access the inner [`Metadata`] via [`ResMetadata::data()`], which panics if
+/// called before initialization (guaranteed not to happen in normal usage).
+#[derive(Default, Clone, Serialize)]
+pub struct ResMetadata {
+ data: Option<Metadata>,
+}
+
+/// Resource indicating whether the output format is JSON.
+///
+/// Set to `true` when `--message-format json` (or similar) is passed.
+/// Used by renderers to decide whether to serialize structs as JSON.
+#[derive(Default, Clone, Serialize)]
+pub struct ResUsingJson {
+ pub using: bool,
+}
+
+impl ResMetadata {
+ /// Returns a reference to the parsed `cargo metadata`.
+ ///
+ /// # Panics
+ ///
+ /// This function does **not** panic in practice, because `ResMetadata` is
+ /// always initialized via [`LazyInit`] inside the program setup, and the
+ /// initialization either succeeds (setting `data` to `Some(...)`) or fails
+ /// by propagating the error from `cmd.exec().unwrap()`. Therefore, by the
+ /// time this getter is called, `self.data` is guaranteed to be `Some`.
+ pub fn data(&self) -> &Metadata {
+ self.data.as_ref().unwrap()
+ }
+}
+
+#[program_setup]
+pub fn cargo_metadata_setup(program: &mut Program<crate::ThisProgram>) {
+ let args = program.get_args().to_vec();
+
+ let (
+ feature_args,
+ manifest_path,
+ message_format,
+ enable_all_features,
+ no_default_features,
+ no_deps,
+ ) = args
+ .pick(&arg![features: Vec<String>])
+ .pick(&arg![manifest_path: Option<String>])
+ .pick_or(&arg![message_format: String], || "disable".to_string())
+ .pick(&arg![all_features: Flag])
+ .pick(&arg![no_default_features: Flag])
+ .pick(&arg![no_deps: Flag])
+ .unwrap();
+
+ // Is Using Json
+ program.with_resource(ResUsingJson {
+ using: message_format.contains("json"),
+ });
+
+ // Current Dir
+ let current_dir = current_dir().unwrap();
+
+ // Leak `feature_args` into a static slice so it can be captured by the metadata closure.
+ // Since the process does not loop, this memory leak is harmless.
+ let feature_args_leaked: &[String] = Box::leak(feature_args.into_boxed_slice());
+ let manifest_path_clone = manifest_path.clone();
+ let current_dir_clone = current_dir.clone();
+
+ program.with_resource(ResMetadata::lazy_init(move || {
+ // Paths
+ let metadata_path = match manifest_path_clone {
+ Some(ref path_str) => PathBuf::from(path_str),
+ None => find_manifest().unwrap(),
+ };
+
+ // Cargo Metadata - bind to a longer-lived variable
+ let mut cmd = MetadataCommand::new();
+ cmd.manifest_path(metadata_path)
+ .current_dir(&current_dir_clone);
+
+ if *enable_all_features {
+ cmd.features(CargoOpt::AllFeatures);
+ }
+
+ if *no_default_features {
+ cmd.features(CargoOpt::NoDefaultFeatures);
+ }
+
+ if *no_deps {
+ cmd.no_deps();
+ }
+
+ cmd.features(CargoOpt::SomeFeatures(feature_args_leaked.to_vec()));
+
+ ResMetadata {
+ data: Some(cmd.exec().unwrap()),
+ }
+ }));
+
+ // Current Dir
+ program.with_resource(ResCurrentDir::from(current_dir));
+}
+
+/// Find `Cargo.toml` by searching upward from `current_dir`.
+fn find_manifest() -> Option<PathBuf> {
+ let mut dir = current_dir().ok()?;
+ loop {
+ let candidate = dir.join(CARGO_TOML);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ if !dir.pop() {
+ return None;
+ }
+ }
+}
diff --git a/mingling_cli/tmpls/lints.tmpl b/mingling_cli/tmpls/lints.tmpl
new file mode 100644
index 0000000..590828a
--- /dev/null
+++ b/mingling_cli/tmpls/lints.tmpl
@@ -0,0 +1,78 @@
+#![allow(unused)]
+use crate::linter::mlint_report::{MlintLevel, MlintReport};
+
+>>>>>>>>>> impls;
+>>>>>>>>>> file_lints;
+
+/// Run all registered lints on a parsed file with its source text.
+pub fn run_all_lints(file: &syn::File, source: &str) -> Vec<MlintReport> {
+ use crate::linter::mlint_attr::{get_mlint_override, MlintLevelOverride};
+
+ // File-level lints (check types, modules, etc.)
+ let mut reports: Vec<MlintReport> = vec![];
+>>>>>>>>>> file_calls;
+
+ // Item-level lints (check each function)
+ for item in &file.items {
+ if let syn::Item::Fn(f) = item {
+>>>>>>>>>> calls;
+ }
+ }
+
+ // Apply file-level #![mlint(allow/warn/deny(...))] overrides
+ for r in &mut reports {
+ if let Some(override_level) = get_mlint_override(&file.attrs, &r.lint_code) {
+ match override_level {
+ MlintLevelOverride::Allow => r.level = MlintLevel::Help,
+ MlintLevelOverride::Deny => r.level = MlintLevel::Error,
+ MlintLevelOverride::Warn => {
+ if r.level != MlintLevel::Error { r.level = MlintLevel::Warning; }
+ }
+ }
+ }
+ }
+ reports.retain(|r| r.level != MlintLevel::Help);
+ reports
+}
+
+#[macro_export]
+macro_rules! assert_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!(!$linter(ast, &source).is_empty());
+ };
+}
+
+#[macro_export]
+macro_rules! assert_not_detected {
+ ($linter:expr, $ast_type:ty => { $($code:tt)* }) => {
+ let source = stringify!($($code)*);
+ let ast: $ast_type = syn::parse_str(&source).unwrap();
+ assert!($linter(ast, &source).is_empty());
+ };
+}
+
+@@@ >>> file_calls
+ reports.extend(<<<name>>>::check_file(file, source));
+@@@ <<<
+
+@@@ >>> file_lints
+pub use <<<name>>>::check_file as <<<name>>>_file;
+@@@ <<<
+
+@@@ >>> impls
+mod <<<mod_name>>>;
+pub use <<<mod_name>>>::linter as <<<mod_name>>>;
+@@@ <<<
+
+@@@ >>> calls
+ let skip = get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Allow);
+ if !skip {
+ let mut rs = <<<name>>>::linter(f.clone(), source);
+ if get_mlint_override(&f.attrs, "<<<name>>>") == Some(MlintLevelOverride::Deny) {
+ for r in &mut rs { r.level = MlintLevel::Error; }
+ }
+ reports.extend(rs);
+ }
+@@@ <<<
diff --git a/mingling_core/src/any.rs b/mingling_core/src/any.rs
index ec29a1b..3e8fdf0 100644
--- a/mingling_core/src/any.rs
+++ b/mingling_core/src/any.rs
@@ -22,14 +22,14 @@ pub struct AnyOutput<G> {
///
/// This is set during construction and used for type-checking
/// in downcast, restore, and is methods.
- pub type_id: std::any::TypeId,
+ pub(crate) type_id: std::any::TypeId,
/// The variant identifier returned by [`Grouped::member_id`] for the
/// concrete type stored in `inner`.
///
/// This is used by the scheduler to dispatch on the correct enum
/// variant when routing the output.
- pub member_id: G,
+ pub(crate) member_id: G,
}
impl<G> AnyOutput<G> {
@@ -45,6 +45,52 @@ impl<G> AnyOutput<G> {
}
}
+ /// Create an `AnyOutput` from a raw value with a manually specified member_id.
+ ///
+ /// This function bypasses the [`Grouped`] trait, meaning the `member_id` you provide
+ /// does **not** have to match the actual concrete type `T`. The scheduler uses
+ /// `member_id` to determine which enum variant the output belongs to, and later
+ /// attempts to restore the value to the concrete type `T` based on that variant.
+ ///
+ /// # Safety
+ ///
+ /// - The caller must ensure that `member_id` correctly corresponds to the concrete
+ /// type `T` according to the scheduling logic. If `member_id` does not match,
+ /// calling [`restore`](Self::restore) or [`downcast`](Self::downcast) with the
+ /// type associated with `member_id` will cause **undefined behavior**.
+ /// - This safety contract is the caller's responsibility; the compiler cannot
+ /// enforce the correspondence between `member_id` and the stored type.
+ pub unsafe fn new_bare<T>(value: T, member_id: G) -> Self
+ where
+ T: Send + 'static,
+ {
+ Self {
+ inner: Box::new(value),
+ type_id: std::any::TypeId::of::<T>(),
+ member_id,
+ }
+ }
+
+ /// Get the [`TypeId`] of the concrete type stored in `inner`.
+ ///
+ /// The `TypeId` is set during construction (via [`AnyOutput::new`] or [`AnyOutput::new_bare`])
+ /// and is used for subsequent downcasting and type checking.
+ pub fn type_id(&self) -> std::any::TypeId {
+ self.type_id
+ }
+
+ /// Get the [`member_id`] of the concrete type stored in `inner`.
+ ///
+ /// [`member_id`] is set during construction (via [`AnyOutput::new`] or [`AnyOutput::new_bare`])
+ /// and identifies which variant of the output enum this value corresponds to.
+ /// The scheduler uses this value to dispatch the output to the correct next step.
+ pub fn member_id(&self) -> G
+ where
+ G: Copy,
+ {
+ self.member_id
+ }
+
/// Attempt to downcast the `AnyOutput` to a concrete type.
///
/// # Errors
@@ -190,7 +236,17 @@ mod tests {
value: i32,
}
- impl Grouped<MockGroup> for AlphaData {
+ /// # Safety
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for AlphaData {
fn member_id() -> MockGroup {
MockGroup::Alpha
}
@@ -202,7 +258,17 @@ mod tests {
name: String,
}
- impl Grouped<MockGroup> for BetaData {
+ /// # Safety
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for BetaData {
fn member_id() -> MockGroup {
MockGroup::Beta
}
@@ -213,7 +279,17 @@ mod tests {
#[cfg_attr(feature = "structural_renderer", derive(serde::Serialize))]
struct GammaData;
- impl Grouped<MockGroup> for GammaData {
+ /// # Safety
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for GammaData {
fn member_id() -> MockGroup {
MockGroup::Gamma
}
@@ -369,7 +445,17 @@ mod tests {
x: i32,
}
- impl Grouped<MockGroup> for SerData {
+ /// SAFETY:
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for SerData {
fn member_id() -> MockGroup {
MockGroup::Gamma
}
@@ -396,13 +482,33 @@ mod tests {
b: String,
}
- impl Grouped<MockGroup> for SerA {
+ /// SAFETY:
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for SerA {
fn member_id() -> MockGroup {
MockGroup::Alpha
}
}
- impl Grouped<MockGroup> for SerB {
+ /// SAFETY:
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockGroup> for SerB {
fn member_id() -> MockGroup {
MockGroup::Beta
}
diff --git a/mingling_core/src/any/group.rs b/mingling_core/src/any/group.rs
index 2813ad5..5e5e347 100644
--- a/mingling_core/src/any/group.rs
+++ b/mingling_core/src/any/group.rs
@@ -2,10 +2,14 @@ use crate::{AnyOutput, ChainProcess, ProgramCollect, Routable};
/// Used to mark a type with a unique enum ID, assisting dynamic dispatch
///
-/// **Note:** Unlike earlier versions, `Grouped` no longer requires `Serialize`
-/// even when the `structural_renderer` feature is enabled. Structured output is
-/// controlled separately via the \[`StructuralData`\] trait.
-pub trait Grouped<Group>
+/// # Safety
+///
+/// The returned `Group` value is an enum variant created by `register_type!` when
+/// registering the type's ID. Whether the variant matches correctly is guaranteed
+/// by `Grouped derive` or macros like `pack!`. If implemented manually, and the
+/// type name written in `member_id()` does not match the actually registered type,
+/// dispatching to that type will result in **100% undefined behavior**.
+pub unsafe trait Grouped<Group>
where
Self: Sized + 'static,
{
diff --git a/mingling_core/src/asset/dispatcher.rs b/mingling_core/src/asset/dispatcher.rs
index 01c9ccf..cb0987d 100644
--- a/mingling_core/src/asset/dispatcher.rs
+++ b/mingling_core/src/asset/dispatcher.rs
@@ -38,7 +38,7 @@ where
note = "When the `dispatch_tree` feature is enabled, the `dispatcher` field no longer exists inside Program. All types are collected at compile time by the `gen_program!()` macro, so the `with_dispatcher` function is no longer needed"
)
)]
- pub fn with_dispatcher<Disp>(&mut self, dispatcher: Disp)
+ pub fn with_dispatcher<Disp>(&mut self, dispatcher: Disp) -> &mut Self
where
Disp: Dispatcher<C> + Send + Sync + 'static,
{
@@ -50,6 +50,7 @@ where
{
let _ = dispatcher;
}
+ self
}
/// Add some dispatchers to the program.
@@ -59,7 +60,7 @@ where
note = "When the `dispatch_tree` feature is enabled, the `dispatcher` field no longer exists inside Program. All types are collected at compile time by the `gen_program!()` macro, so the `with_dispatcher` function is no longer needed"
)
)]
- pub fn with_dispatchers<D>(&mut self, dispatchers: D)
+ pub fn with_dispatchers<D>(&mut self, dispatchers: D) -> &mut Self
where
D: Into<Dispatchers<C>>,
{
@@ -72,6 +73,7 @@ where
{
let _ = dispatchers;
}
+ self
}
}
diff --git a/mingling_core/src/asset/global_resource.rs b/mingling_core/src/asset/global_resource.rs
index 29e1136..a610378 100644
--- a/mingling_core/src/asset/global_resource.rs
+++ b/mingling_core/src/asset/global_resource.rs
@@ -13,10 +13,14 @@ where
C: ProgramCollect<Enum = C>,
{
/// Insert a resource of the given type, cloning the provided value into the store
- pub fn with_resource<Res: 'static + Send + Sync + ResourceMarker>(&mut self, res: Res) {
+ pub fn with_resource<Res: 'static + Send + Sync + ResourceMarker>(
+ &mut self,
+ res: Res,
+ ) -> &mut Self {
if let Ok(mut guard) = self.resources.lock() {
guard.insert(TypeId::of::<Res>(), Box::new(Arc::new(res)));
}
+ self
}
/// Modify a resource by type, applying a closure to the resource if present
diff --git a/mingling_core/src/lib.rs b/mingling_core/src/lib.rs
index 2cb24b9..31476c9 100644
--- a/mingling_core/src/lib.rs
+++ b/mingling_core/src/lib.rs
@@ -90,8 +90,14 @@ pub mod setup {
/// Private API — not intended for direct use.
#[doc(hidden)]
pub mod __private {
+ use crate::ProgramCollect;
+
/// Sealed trait for `StructuralData` — only implementable via derive macro.
- pub trait StructuralDataSealed {}
+ pub trait StructuralDataSealed<C>
+ where
+ C: ProgramCollect<Enum = C>,
+ {
+ }
/// Re-export so the derive macro can reference the trait without
/// conflicting with the derive macro name at `::mingling::StructuralData`.
diff --git a/mingling_core/src/program/collection/mock.rs b/mingling_core/src/program/collection/mock.rs
index 5847f10..dbe4789 100644
--- a/mingling_core/src/program/collection/mock.rs
+++ b/mingling_core/src/program/collection/mock.rs
@@ -23,9 +23,12 @@ pub enum MockProgramCollect {
Bar,
}
-impl Grouped<MockProgramCollect> for MockProgramCollect {
+/// SAFETY: This is a mock type used only for temporary testing.
+/// It will never actually enter the macro system.
+/// The internal `panic!` ensures that `member_id` will never be executed.
+unsafe impl Grouped<MockProgramCollect> for MockProgramCollect {
fn member_id() -> MockProgramCollect {
- MockProgramCollect::Foo
+ panic!("Attempting to read an unsafe enum type");
}
}
diff --git a/mingling_core/src/program/hook.rs b/mingling_core/src/program/hook.rs
index db1691b..7d94a21 100644
--- a/mingling_core/src/program/hook.rs
+++ b/mingling_core/src/program/hook.rs
@@ -144,8 +144,9 @@ where
{
/// Adds a typed hook to the program. The hook will be called at the appropriate
/// lifecycle events.
- pub fn with_hook(&mut self, hook: ProgramHook<C>) {
+ pub fn with_hook(&mut self, hook: ProgramHook<C>) -> &mut Self {
self.hooks.push(hook);
+ self
}
pub(crate) fn run_hook_on_begin(&self, info: HookBeginInfo) {
@@ -694,7 +695,17 @@ mod tests {
}
}
- impl Grouped<MockHookEnum> for MockHookEnum {
+ /// SAFETY:
+ ///
+ /// This implementation is only for testing purposes to satisfy trait bounds.
+ /// Since this code only constructs `AnyOutput` and calls methods like
+ /// `downcast`, `is`, `restore`, `route_chain`, and `route_renderer` —
+ /// none of which involve `ProgramCollect::do_chain` or
+ /// `ProgramCollect::render` — the type/member_id correspondence is
+ /// never exploited in an unsafe way here.
+ /// The caller must ensure that the associated `member_id` correctly
+ /// corresponds to the type's role in the group.
+ unsafe impl Grouped<MockHookEnum> for MockHookEnum {
fn member_id() -> MockHookEnum {
MockHookEnum::A
}
diff --git a/mingling_core/src/program/once_exec.rs b/mingling_core/src/program/once_exec.rs
index 9d6f1e4..a846d04 100644
--- a/mingling_core/src/program/once_exec.rs
+++ b/mingling_core/src/program/once_exec.rs
@@ -79,23 +79,12 @@ where
};
// Read exit code
- let exit_code = result.exit_code;
-
// Render result
- if stdout_setting.render_output && !result.is_empty() {
- print!("{result}");
-
- if let Err(e) = std::io::Write::flush(&mut std::io::stdout())
- && stdout_setting.error_output
- {
- eprintln!("{e}");
- 1
- } else {
- exit_code
- }
- } else {
- exit_code
+ if stdout_setting.render_output {
+ result.std_print();
}
+
+ result.exit_code
}
/// Run the command line program, then exit
@@ -217,23 +206,12 @@ where
};
// Read exit code
- let exit_code = result.exit_code;
-
// Render result
- if stdout_setting.render_output && !result.is_empty() {
- print!("{result}");
-
- if let Err(e) = std::io::Write::flush(&mut std::io::stdout())
- && stdout_setting.error_output
- {
- eprintln!("{e}");
- 1
- } else {
- exit_code
- }
- } else {
- exit_code
+ if stdout_setting.render_output {
+ result.std_print();
}
+
+ result.exit_code
}
/// Run the command line program, then exit
diff --git a/mingling_core/src/program/setup.rs b/mingling_core/src/program/setup.rs
index 838c29a..a8ff114 100644
--- a/mingling_core/src/program/setup.rs
+++ b/mingling_core/src/program/setup.rs
@@ -29,8 +29,9 @@ where
C: ProgramCollect<Enum = C>,
{
/// Load and execute init logic
- pub fn with_setup<S: ProgramSetup<C> + 'static>(&mut self, setup: S) {
+ pub fn with_setup<S: ProgramSetup<C> + 'static>(&mut self, setup: S) -> &mut Self {
S::setup(setup, self);
+ self
}
}
diff --git a/mingling_core/src/renderer/render_result.rs b/mingling_core/src/renderer/render_result.rs
index 2c60fa4..c38522a 100644
--- a/mingling_core/src/renderer/render_result.rs
+++ b/mingling_core/src/renderer/render_result.rs
@@ -1,13 +1,24 @@
use std::{
fmt::{Display, Formatter},
io::Write,
- ops::Deref,
};
+use crate::RenderResultMode::{Stderr, Stdout};
+
/// Render result, containing the rendered text content.
#[derive(Default, Debug, PartialEq)]
pub struct RenderResult {
- render_text: String,
+ /// Whether the output should be written immediately.
+ ///
+ /// When set to `true`, rendered content will be flushed to stdout/stderr
+ /// in real time while also being collected in the render buffer.
+ immediate_output: bool,
+
+ /// The buffered render output, stored as a list of (text, mode) pairs.
+ ///
+ /// Each entry contains a rendered string together with a `RenderResultMode`
+ /// indicating whether it should be output to stdout or stderr.
+ render_buffer: Vec<(String, RenderResultMode)>,
/// The exit code to return from the rendering process.
///
@@ -16,12 +27,41 @@ pub struct RenderResult {
pub exit_code: i32,
}
+/// Enum representing the output mode for render results.
+///
+/// This determines whether the rendered content should be directed to standard
+/// output or standard error.
+///
+/// # Variants
+///
+/// * `Stdout` - Output will be written to standard output (stdout).
+/// * `Stderr` - Output will be written to standard error (stderr).
+#[repr(u8)]
+#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
+pub enum RenderResultMode {
+ /// Standard output (stdout).
+ #[default]
+ Stdout = 0,
+
+ /// Standard error (stderr).
+ Stderr = 1,
+}
+
+impl<F> From<F> for RenderResult
+where
+ F: FnOnce() -> RenderResult,
+{
+ fn from(value: F) -> Self {
+ value()
+ }
+}
+
impl Write for RenderResult {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let s = std::str::from_utf8(buf).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "not valid UTF-8")
})?;
- self.render_text.push_str(s);
+ self.append_to_buffer(s, Stdout);
Ok(buf.len())
}
@@ -32,15 +72,7 @@ impl Write for RenderResult {
impl Display for RenderResult {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- writeln!(f, "{}", self.render_text.trim())
- }
-}
-
-impl Deref for RenderResult {
- type Target = str;
-
- fn deref(&self) -> &Self::Target {
- &self.render_text
+ write!(f, "{}", render_result_to_string(self).trim())
}
}
@@ -69,40 +101,31 @@ impl_from_int!(i32, i16, i8, u32, u16, u8, usize);
impl From<&String> for RenderResult {
fn from(value: &String) -> Self {
- RenderResult {
- render_text: value.clone(),
- exit_code: 0,
- }
+ string_to_render_result(value, Stdout)
}
}
impl From<String> for RenderResult {
fn from(value: String) -> Self {
- RenderResult {
- render_text: value,
- exit_code: 0,
- }
+ string_to_render_result(value, Stdout)
}
}
impl From<&str> for RenderResult {
fn from(value: &str) -> Self {
- RenderResult {
- render_text: value.to_string(),
- exit_code: 0,
- }
+ string_to_render_result(value, Stdout)
}
}
impl From<RenderResult> for String {
fn from(result: RenderResult) -> Self {
- result.render_text
+ render_result_to_string(&result)
}
}
impl From<&RenderResult> for String {
fn from(result: &RenderResult) -> Self {
- result.render_text.clone()
+ render_result_to_string(result)
}
}
@@ -124,21 +147,139 @@ impl RenderResult {
Self::default()
}
+ /// Marks the render result for immediate output, bypassing any buffering or
+ /// deferred rendering.
+ ///
+ /// When set, the rendered content will be both collected in the result and
+ /// immediately flushed to stdout/stderr in real time, rather than being
+ /// deferred for later display.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.immediate_output();
+ /// ```
+ pub fn immediate_output(&mut self) -> &mut Self {
+ self.immediate_output = true;
+ self
+ }
+
+ /// Appends the given text and mode to the render buffer.
+ ///
+ /// Unlike `print` and `println` which only store plain text in a single string,
+ /// this method stores the text along with a `RenderResultMode` that indicates
+ /// whether the output should be directed to stdout or stderr. This allows for
+ /// more fine-grained control over output routing when the buffer is later flushed.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - The text content to append to the buffer.
+ /// * `mode` - The output mode (`Stdout` or `Stderr`) indicating where the text
+ /// should be directed.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::{RenderResult, RenderResultMode};
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.append_to_buffer("Hello", RenderResultMode::Stdout);
+ /// result.append_to_buffer("Error message", RenderResultMode::Stderr);
+ /// ```
+ pub fn append_to_buffer(&mut self, text: impl Into<String>, mode: RenderResultMode) {
+ self.render_buffer.push((text.into(), mode));
+ }
+
+ /// Appends the given text followed by a newline, along with the mode, to the render buffer.
+ ///
+ /// This is a convenience method that calls `append_to_buffer` for the text and then
+ /// appends a newline with the same mode.
+ ///
+ /// # Arguments
+ ///
+ /// * `text` - The text content to append to the buffer.
+ /// * `mode` - The output mode (`Stdout` or `Stderr`) indicating where the text
+ /// should be directed.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::{RenderResult, RenderResultMode};
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.append_line_to_buffer("Hello", RenderResultMode::Stdout);
+ /// result.append_line_to_buffer("Warning", RenderResultMode::Stderr);
+ /// ```
+ pub fn append_line_to_buffer(&mut self, text: impl Into<String>, mode: RenderResultMode) {
+ self.append_to_buffer(text, mode);
+ self.append_to_buffer("\n", mode);
+ }
+
+ /// Appends the contents of another `RenderResult` to this one.
+ ///
+ /// If this `RenderResult` has `immediate_output` enabled but the other does not,
+ /// the other's content will be immediately flushed to the appropriate output stream
+ /// (stdout/stderr) while also being appended to the render buffer.
+ ///
+ /// The `exit_code` of the other result is **not** transferred — only the buffered
+ /// content and the `immediate_output` flag of the other result are merged.
+ ///
+ /// # Arguments
+ ///
+ /// * `other` - The `RenderResult` whose contents should be appended.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::{RenderResult, RenderResultMode};
+ ///
+ /// let mut dest = RenderResult::default();
+ /// let mut src = RenderResult::default();
+ ///
+ /// src.append_to_buffer("Hello", RenderResultMode::Stdout);
+ /// src.append_to_buffer(" Error", RenderResultMode::Stderr);
+ ///
+ /// dest.append_other(src);
+ /// assert_eq!(dest.to_string(), "Hello Error");
+ /// ```
+ pub fn append_other(&mut self, other: impl Into<RenderResult>) {
+ let other = other.into();
+
+ // If self has immediate output enabled, but the input does not, the input needs immediate output.
+ let immediate_output = !other.immediate_output && self.immediate_output;
+
+ for i in other.render_buffer {
+ if immediate_output {
+ match &i.1 {
+ Stdout => print!("{}", i.0),
+ Stderr => eprint!("{}", i.0),
+ }
+ }
+ self.render_buffer.push(i);
+ }
+ }
+
/// Appends the given text to the rendered content.
///
/// # Examples
///
/// ```
/// use mingling_core::RenderResult;
- /// use std::ops::Deref;
///
/// let mut result = RenderResult::default();
/// result.print("Hello");
/// result.print(", world!");
- /// assert_eq!(result.deref(), "Hello, world!");
+ /// assert_eq!(result.to_string(), "Hello, world!");
/// ```
- pub fn print(&mut self, text: impl AsRef<str>) {
- self.render_text.push_str(text.as_ref());
+ pub fn print(&mut self, text: impl Into<String>) {
+ let text = text.into();
+ if self.immediate_output {
+ print!("{}", text)
+ }
+ self.append_to_buffer(text, Stdout);
}
/// Appends the given text followed by a newline to the rendered content.
@@ -147,16 +288,58 @@ impl RenderResult {
///
/// ```
/// use mingling_core::RenderResult;
- /// use std::ops::Deref;
///
/// let mut result = RenderResult::default();
/// result.println("First line");
/// result.println("Second line");
- /// assert_eq!(result.deref(), "First line\nSecond line\n");
+ /// assert_eq!(result.to_string(), "First line\nSecond line");
+ /// ```
+ pub fn println(&mut self, text: impl Into<String>) {
+ let text = text.into();
+ if self.immediate_output {
+ println!("{}", text)
+ }
+ self.append_line_to_buffer(text, Stdout);
+ }
+
+ /// Appends the given text to the rendered content, marking it for stderr output.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.eprint("Hello");
+ /// result.eprint(", world!");
+ /// assert_eq!(result.to_string(), "Hello, world!");
+ /// ```
+ pub fn eprint(&mut self, text: impl Into<String>) {
+ let text = text.into();
+ if self.immediate_output {
+ eprint!("{}", text)
+ }
+ self.append_to_buffer(text, Stderr);
+ }
+
+ /// Appends the given text followed by a newline to the rendered content, marking it for stderr output.
+ ///
+ /// # Examples
+ ///
/// ```
- pub fn println(&mut self, text: impl AsRef<str>) {
- self.render_text.push_str(text.as_ref());
- self.render_text.push('\n');
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.eprintln("First line");
+ /// result.eprintln("Second line");
+ /// assert_eq!(result.to_string(), "First line\nSecond line");
+ /// ```
+ pub fn eprintln(&mut self, text: impl Into<String>) {
+ let text = text.into();
+ if self.immediate_output {
+ println!("{}", text)
+ }
+ self.append_line_to_buffer(text, Stderr);
}
/// Clears all rendered content.
@@ -174,7 +357,148 @@ impl RenderResult {
/// assert!(result.is_empty());
/// ```
pub fn clear(&mut self) {
- self.render_text.clear();
+ self.render_buffer.clear();
+ }
+
+ /// Outputs all buffered content to stdout and stderr according to their respective modes.
+ ///
+ /// Iterates through the render buffer and prints each buffered string to the appropriate
+ /// output stream — stdout for `Stdout` entries and stderr for `Stderr` entries.
+ ///
+ /// This method is typically used to flush the buffered output at the end of rendering,
+ /// ensuring that all output is displayed in the correct order and to the correct stream.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::{RenderResult, RenderResultMode};
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.append_to_buffer("Hello", RenderResultMode::Stdout);
+ /// result.append_to_buffer("Error", RenderResultMode::Stderr);
+ /// result.std_print(); // prints "Hello" to stdout and "Error" to stderr
+ /// ```
+ pub fn std_print(&self) {
+ for (content, mode) in self.render_buffer.iter() {
+ match mode {
+ Stdout => print!("{}", content),
+ Stderr => eprint!("{}", content),
+ }
+ }
+ }
+
+ /// Returns the total number of characters (in terms of `char` count) in the buffered render output.
+ ///
+ /// This counts the length across all buffered entries, regardless of whether they are
+ /// destined for stdout or stderr.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.print("Hello");
+ /// result.print(", 世界");
+ /// assert_eq!(result.len(), 9); // "Hello, 世界" has 9 chars
+ /// ```
+ pub fn len(&self) -> usize {
+ self.render_buffer
+ .iter()
+ .map(|(s, _)| s.chars().count())
+ .sum()
+ }
+
+ /// Returns `true` if the buffered render output contains no characters.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// assert!(result.is_empty());
+ /// result.print("Hello");
+ /// assert!(!result.is_empty());
+ /// ```
+ pub fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ /// Trims leading and trailing whitespace from the buffered render output.
+ ///
+ /// This method processes the render buffer as follows:
+ /// - If the buffer is empty, it returns `self` unchanged.
+ /// - If there is only one entry, whitespace is trimmed from both the start and end of that
+ /// single entry.
+ /// - If there are multiple entries, whitespace is trimmed from the start of the first entry
+ /// and the end of the last entry.
+ ///
+ /// Whitespace in the middle entries is preserved. This is useful for cleaning up output
+ /// without removing intentional spacing between separately buffered segments.
+ ///
+ /// # Returns
+ ///
+ /// A new `RenderResult` with the same `immediate_output` flag and `exit_code`, but with
+ /// trimmed text content.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use mingling_core::RenderResult;
+ ///
+ /// let mut result = RenderResult::default();
+ /// result.print(" Hello, world! ");
+ /// let trimmed = result.trim_buffer();
+ /// assert_eq!(trimmed.to_string().trim(), "Hello, world!");
+ /// ```
+ pub fn trim_buffer(self) -> RenderResult {
+ if self.render_buffer.is_empty() {
+ return self;
+ }
+
+ let mut buffer = self.render_buffer;
+ if buffer.len() == 1 {
+ // Only one entry: trim both start and end of this single entry
+ let (text, mode) = buffer.remove(0);
+ buffer.push((text.trim().to_string(), mode));
+ } else {
+ // Multiple entries: trim start of first, trim end of last
+ let first_len = buffer.len();
+
+ // Trim start of first entry
+ let (first_text, first_mode) = buffer.remove(0);
+ let trimmed_first = first_text.trim_start().to_string();
+ buffer.insert(0, (trimmed_first, first_mode));
+
+ // Trim end of last entry
+ let (last_text, last_mode) = buffer.remove(first_len - 1);
+ let trimmed_last = last_text.trim_end().to_string();
+ buffer.push((trimmed_last, last_mode));
+ }
+
+ RenderResult {
+ render_buffer: buffer,
+ immediate_output: self.immediate_output,
+ exit_code: self.exit_code,
+ }
+ }
+}
+
+#[inline(always)]
+fn render_result_to_string(result: &RenderResult) -> String {
+ let mut buffer = String::new();
+ for item in result.render_buffer.iter() {
+ buffer += &item.0;
+ }
+ buffer
+}
+
+#[inline(always)]
+fn string_to_render_result(string: impl Into<String>, mode: RenderResultMode) -> RenderResult {
+ RenderResult {
+ render_buffer: vec![(string.into(), mode)],
+ ..Default::default()
}
}
@@ -191,20 +515,6 @@ mod tests {
}
#[test]
- fn print_appends_text() {
- let mut result = RenderResult::default();
- result.print("Hello");
- assert_eq!(result.deref(), "Hello");
- }
-
- #[test]
- fn println_appends_text_with_newline() {
- let mut result = RenderResult::default();
- result.println("Hello");
- assert_eq!(result.deref(), "Hello\n");
- }
-
- #[test]
fn clear_empties_content() {
let mut result = RenderResult::default();
result.print("something");
@@ -222,14 +532,6 @@ mod tests {
}
#[test]
- fn write_appends_utf8_bytes() {
- let mut result = RenderResult::default();
- let n = IoWrite::write(&mut result, b"hello").unwrap();
- assert_eq!(n, 5);
- assert_eq!(result.deref(), "hello");
- }
-
- #[test]
fn write_with_invalid_utf8_returns_error() {
let mut result = RenderResult::default();
let err = IoWrite::write(&mut result, &[0xff, 0xfe]).unwrap_err();
@@ -241,16 +543,7 @@ mod tests {
let mut result = RenderResult::default();
result.print(" hello world \n");
let formatted = format!("{}", result);
- assert_eq!(formatted, "hello world\n");
- }
-
- #[test]
- fn deref_exposes_inner_text_as_str() {
- let mut result = RenderResult::default();
- result.print("test");
-
- let s: &str = &result;
- assert_eq!(s, "test");
+ assert_eq!(formatted, "hello world");
}
#[test]
@@ -270,4 +563,72 @@ mod tests {
// original is still usable
assert!(!result.is_empty());
}
+
+ #[test]
+ fn trim_empty_buffer_returns_self() {
+ let result = RenderResult::default();
+ let trimmed = result.trim_buffer();
+ assert!(trimmed.is_empty());
+ assert_eq!(trimmed.exit_code, 0);
+ }
+
+ #[test]
+ fn trim_single_entry_trims_both_ends() {
+ let mut result = RenderResult::default();
+ result.print(" Hello, world! ");
+ let trimmed = result.trim_buffer();
+ assert_eq!(trimmed.to_string(), "Hello, world!");
+ }
+
+ #[test]
+ fn trim_single_entry_nothing_to_trim() {
+ let mut result = RenderResult::default();
+ result.print("Hello");
+ let trimmed = result.trim_buffer();
+ assert_eq!(trimmed.to_string(), "Hello");
+ }
+
+ #[test]
+ fn trim_multiple_entries_trims_first_start_and_last_end() {
+ let mut result = RenderResult::default();
+ result.print(" Hello");
+ result.print(" World ");
+ result.print("! ");
+ let trimmed = result.trim_buffer();
+ // first entry trim_start: "Hello"
+ // middle entry unchanged: " World "
+ // last entry trim_end: "!"
+ assert_eq!(trimmed.to_string(), "Hello World !");
+ }
+
+ #[test]
+ fn trim_multiple_entries_only_whitespace_first_entry() {
+ let mut result = RenderResult::default();
+ result.print(" ");
+ result.print("Hello");
+ result.print(" ");
+ let trimmed = result.trim_buffer();
+ // first entry trim_start: ""
+ // middle entry unchanged: "Hello"
+ // last entry trim_end: ""
+ assert_eq!(trimmed.to_string(), "Hello");
+ }
+
+ #[test]
+ fn trim_preserves_exit_code() {
+ let mut result = RenderResult::new();
+ result.exit_code = 42;
+ result.print(" test ");
+ let trimmed = result.trim_buffer();
+ assert_eq!(trimmed.exit_code, 42);
+ }
+
+ #[test]
+ fn trim_preserves_stderr_mode() {
+ let mut result = RenderResult::default();
+ result.eprint(" error ");
+ let trimmed = result.trim_buffer();
+ assert_eq!(trimmed.render_buffer[0].1, RenderResultMode::Stderr);
+ assert_eq!(trimmed.to_string(), "error");
+ }
}
diff --git a/mingling_core/src/renderer/structural.rs b/mingling_core/src/renderer/structural.rs
index 30255aa..0449e72 100644
--- a/mingling_core/src/renderer/structural.rs
+++ b/mingling_core/src/renderer/structural.rs
@@ -1,5 +1,5 @@
use crate::{
- RenderResult, StructuralRendererSetting,
+ ProgramCollect, RenderResult, StructuralRendererSetting,
renderer::structural::error::StructuralRendererSerializeError,
};
use serde::Serialize;
@@ -24,11 +24,15 @@ impl StructuralRenderer {
///
/// Returns `Err(StructuralRendererSerializeError)` if serialization fails.
#[allow(unused_variables)]
- pub fn render<T: StructuralData + Send>(
+ pub fn render<T, C>(
data: &T,
setting: &StructuralRendererSetting,
r: &mut RenderResult,
- ) -> Result<(), StructuralRendererSerializeError> {
+ ) -> Result<(), StructuralRendererSerializeError>
+ where
+ T: StructuralData<C> + Send,
+ C: ProgramCollect<Enum = C>,
+ {
match setting {
StructuralRendererSetting::Disable => Ok(()),
#[cfg(feature = "json_serde_fmt")]
@@ -148,7 +152,7 @@ impl StructuralRenderer {
#[cfg(test)]
mod tests {
use super::*;
- use crate::RenderResult;
+ use crate::{MockProgramCollect, RenderResult};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize)]
@@ -157,8 +161,8 @@ mod tests {
value: i32,
}
- impl crate::__private::StructuralDataSealed for TestData {}
- impl StructuralData for TestData {}
+ impl crate::__private::StructuralDataSealed<MockProgramCollect> for TestData {}
+ impl StructuralData<MockProgramCollect> for TestData {}
fn test_data() -> TestData {
TestData {
diff --git a/mingling_core/src/renderer/structural/structural_data.rs b/mingling_core/src/renderer/structural/structural_data.rs
index 1cafac3..583808c 100644
--- a/mingling_core/src/renderer/structural/structural_data.rs
+++ b/mingling_core/src/renderer/structural/structural_data.rs
@@ -1,5 +1,7 @@
use serde::Serialize;
+use crate::ProgramCollect;
+
/// Marker trait for types that support structured output (JSON / YAML / TOML / RON).
///
/// This trait is a **supertrait** of `serde::Serialize` and is sealed via
@@ -12,4 +14,8 @@ use serde::Serialize;
/// These entry points also register the type in the global `STRUCTURED_TYPES`
/// registry, which is required for the `structural_render` match arm to be generated.
#[doc(hidden)]
-pub trait StructuralData: Serialize + crate::__private::StructuralDataSealed {}
+pub trait StructuralData<C>: Serialize + crate::__private::StructuralDataSealed<C>
+where
+ C: ProgramCollect<Enum = C>,
+{
+}
diff --git a/mingling_core/tests/test-all/tests/integration.rs b/mingling_core/tests/test-all/tests/integration.rs
index 3581702..d36b9df 100644
--- a/mingling_core/tests/test-all/tests/integration.rs
+++ b/mingling_core/tests/test-all/tests/integration.rs
@@ -1,13 +1,12 @@
use mingling::Flag;
-use mingling::StructuralRenderer;
-use mingling::StructuralRendererSetting;
-use mingling::MockProgramCollect;
use mingling::NextProcess;
-use mingling::StructuralData;
use mingling::Node;
use mingling::Program;
use mingling::RenderResult;
use mingling::StringVec;
+use mingling::StructuralData;
+use mingling::StructuralRenderer;
+use mingling::StructuralRendererSetting;
use mingling::comp::{ShellContext, ShellFlag, Suggest};
use mingling::core_res::ResREPL;
use mingling::hook::ProgramHook;
@@ -86,7 +85,7 @@ fn test_render_result_default() {
fn test_render_result_print() {
let mut r = RenderResult::default();
r.print("hello");
- assert_eq!(&*r, "hello");
+ assert_eq!(r.to_string().as_str(), "hello");
}
// StructuralRenderer
@@ -125,13 +124,13 @@ fn test_structural_renderer_json() {
#[test]
fn test_is_completing() {
- let program: Program<MockProgramCollect> = Program::new_with_args(["app", "__comp"]);
+ let program: Program<crate::ThisProgram> = Program::new_with_args(["app", "__comp"]);
assert!(program.is_completing());
}
#[test]
fn test_is_not_completing() {
- let program: Program<MockProgramCollect> = Program::new_with_args(["app", "greet"]);
+ let program: Program<crate::ThisProgram> = Program::new_with_args(["app", "greet"]);
assert!(!program.is_completing());
}
@@ -141,7 +140,7 @@ fn test_is_not_completing() {
fn test_hook_setup() {
static CALLED: AtomicBool = AtomicBool::new(false);
- let hook = ProgramHook::<MockProgramCollect>::empty().on_begin::<_, ()>(|_| {
+ let hook = ProgramHook::<crate::ThisProgram>::empty().on_begin::<_, ()>(|_| {
CALLED.store(true, Ordering::SeqCst);
});
@@ -166,3 +165,5 @@ fn test_string_vec_from_array() {
let v: Vec<String> = sv.into();
assert_eq!(v, vec!["a", "b", "c"]);
}
+
+mingling::macros::gen_program!();
diff --git a/mingling_core/tests/test-basic/tests/integration.rs b/mingling_core/tests/test-basic/tests/integration.rs
index 7cd7b8c..e51992c 100644
--- a/mingling_core/tests/test-basic/tests/integration.rs
+++ b/mingling_core/tests/test-basic/tests/integration.rs
@@ -60,7 +60,7 @@ fn test_render_result_default() {
fn test_render_result_print() {
let mut r = RenderResult::default();
r.print("hello");
- assert_eq!(&*r, "hello");
+ assert_eq!(r.to_string().as_str(), "hello");
}
#[test]
diff --git a/mingling_core/tests/test-dispatch-tree/tests/integration.rs b/mingling_core/tests/test-dispatch-tree/tests/integration.rs
index 7cd7b8c..e51992c 100644
--- a/mingling_core/tests/test-dispatch-tree/tests/integration.rs
+++ b/mingling_core/tests/test-dispatch-tree/tests/integration.rs
@@ -60,7 +60,7 @@ fn test_render_result_default() {
fn test_render_result_print() {
let mut r = RenderResult::default();
r.print("hello");
- assert_eq!(&*r, "hello");
+ assert_eq!(r.to_string().as_str(), "hello");
}
#[test]
diff --git a/mingling_core/tests/test-repl/tests/integration.rs b/mingling_core/tests/test-repl/tests/integration.rs
index 1792525..4de0e8f 100644
--- a/mingling_core/tests/test-repl/tests/integration.rs
+++ b/mingling_core/tests/test-repl/tests/integration.rs
@@ -59,5 +59,5 @@ fn test_render_result_default() {
fn test_render_result_print() {
let mut r = RenderResult::default();
r.print("hello");
- assert_eq!(&*r, "hello");
+ assert_eq!(r.to_string().as_str(), "hello");
}
diff --git a/mingling_core/tests/test-structural-renderer/tests/integration.rs b/mingling_core/tests/test-structural-renderer/tests/integration.rs
index 3c3c6db..e4057f8 100644
--- a/mingling_core/tests/test-structural-renderer/tests/integration.rs
+++ b/mingling_core/tests/test-structural-renderer/tests/integration.rs
@@ -1,4 +1,4 @@
-use mingling::{StructuralRenderer, StructuralRendererSetting, RenderResult, StructuralData};
+use mingling::{RenderResult, StructuralData, StructuralRenderer, StructuralRendererSetting};
use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Serialize, StructuralData)]
@@ -17,7 +17,8 @@ fn test_data() -> TestData {
#[test]
fn test_render_disable() {
let mut r = RenderResult::default();
- let result = StructuralRenderer::render(&test_data(), &StructuralRendererSetting::Disable, &mut r);
+ let result =
+ StructuralRenderer::render(&test_data(), &StructuralRendererSetting::Disable, &mut r);
assert!(result.is_ok());
assert!(r.is_empty());
}
@@ -73,3 +74,6 @@ fn test_render_ron() {
assert!(output.contains("value:"));
assert!(output.contains("42"));
}
+
+mingling::macros::gen_program!();
+
diff --git a/mingling_macros/src/attr/chain.rs b/mingling_macros/src/attr/chain.rs
index dbd87dd..dc28a39 100644
--- a/mingling_macros/src/attr/chain.rs
+++ b/mingling_macros/src/attr/chain.rs
@@ -44,7 +44,6 @@ fn generate_proc_fn(
previous_type: &TypePath,
is_async_fn: bool,
is_unit_return: bool,
- origin_return_type: &proc_macro2::TokenStream,
) -> proc_macro2::TokenStream {
let immut_resource_stmts = generate_immut_resource_bindings(resources.iter(), program_type);
let mut_resources: Vec<_> = resources.iter().filter(|r| r.is_mut).collect();
@@ -82,8 +81,14 @@ fn generate_proc_fn(
}
};
- // Convert the function call to a syn::Stmt so existing wrapping functions can use it
- let fn_call_expr: syn::Expr = syn::parse_quote! { #fn_call };
+ // Convert the function call to a syn::Stmt so existing wrapping functions can use it.
+ // For non-unit returns, wrap in `to_chain()` so mutable-resource closures
+ // return `ChainProcess<C>` (as required by `__modify_res_and_return_route`).
+ let fn_call_expr: syn::Expr = if is_unit_return {
+ syn::parse_quote! { #fn_call }
+ } else {
+ syn::parse_quote! { ::mingling::Routable::<#program_type>::to_chain(#fn_call) }
+ };
let fn_call_stmt = syn::Stmt::Expr(fn_call_expr, None);
let wrapped_body = if is_async_fn && !mut_resources.is_empty() {
@@ -124,9 +129,7 @@ fn generate_proc_fn(
};
quote! {
let __chain_result = { #body };
- <#origin_return_type as ::std::convert::Into<
- ::mingling::ChainProcess<#program_type>
- >>::into(__chain_result)
+ ::mingling::Routable::<#program_type>::to_chain(__chain_result)
}
};
@@ -249,12 +252,6 @@ pub(crate) fn chain_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
// Always use the default crate-defined program path
let program_type = crate::default_program_path();
- // Extract the user's return type for the explicit Into turbofish
- let origin_return_type = match &input_fn.sig.output {
- ReturnType::Type(_, ty) => quote! { #ty },
- ReturnType::Default => quote! { () },
- };
-
// Generate the `proc` function for the Chain impl
let proc_fn = generate_proc_fn(
fn_name,
@@ -267,7 +264,6 @@ pub(crate) fn chain_attr(attr: TokenStream, item: TokenStream) -> TokenStream {
#[cfg(not(feature = "async"))]
false,
is_unit_return,
- &origin_return_type,
);
// Preserve the original function untouched
diff --git a/mingling_macros/src/derive/grouped.rs b/mingling_macros/src/derive/grouped.rs
index 307aab6..a00eea1 100644
--- a/mingling_macros/src/derive/grouped.rs
+++ b/mingling_macros/src/derive/grouped.rs
@@ -16,7 +16,11 @@ pub(crate) fn derive_grouped(input: TokenStream) -> TokenStream {
let expanded = quote! {
::mingling::macros::register_type!(#struct_name);
- impl ::mingling::Grouped<#group_ident> for #struct_name {
+ /// SAFETY: This is an internal implementation of the `Grouped` derive macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<#group_ident> for #struct_name {
fn member_id() -> #group_ident {
#group_ident::#struct_name
}
@@ -46,7 +50,11 @@ pub fn derive_grouped_serialize(input: TokenStream) -> TokenStream {
::mingling::macros::register_type!(#struct_name);
- impl ::mingling::Grouped<#group_ident> for #struct_name {
+ /// SAFETY: This is an internal implementation of the `Grouped` derive macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<#group_ident> for #struct_name {
fn member_id() -> #group_ident {
#group_ident::#struct_name
}
diff --git a/mingling_macros/src/extensions.rs b/mingling_macros/src/extensions.rs
index 022761b..c2fdb30 100644
--- a/mingling_macros/src/extensions.rs
+++ b/mingling_macros/src/extensions.rs
@@ -13,6 +13,10 @@ use syn::{Ident, Token};
#[cfg(feature = "extra_macros")]
pub(crate) mod routeify;
+/// Extension: `#[renderify]` — transforms `expr?` into `render_route!(expr)`.
+#[cfg(feature = "extra_macros")]
+pub(crate) mod renderify;
+
/// Extension: `#[buffer]` — wraps a unit-returning function to return `RenderResult`.
pub(crate) mod buffer;
diff --git a/mingling_macros/src/extensions/renderify.rs b/mingling_macros/src/extensions/renderify.rs
new file mode 100644
index 0000000..9be8acc
--- /dev/null
+++ b/mingling_macros/src/extensions/renderify.rs
@@ -0,0 +1,48 @@
+//! The `#[renderify]` extension — transforms `expr?` into `render_route!(expr)`.
+//!
+//! Designed as an extension for the Mingling attribute macro system, intended
+//! to be used with `#[renderer(renderify)]`, `#[help(renderify)]`,
+//! or standalone as `#[renderify]`.
+//!
+//! # How it works
+//!
+//! The macro parses the function AST and replaces every `Expr::Try` node with an
+//! equivalent `render_route!(expr)` invocation, which routes errors to the
+//! rendering pipeline via `crate::ThisProgram::render(AnyOutput::new(e))`.
+
+use proc_macro::TokenStream;
+use quote::ToTokens;
+use syn::spanned::Spanned;
+use syn::visit_mut::VisitMut;
+use syn::{Expr, ItemFn, parse_macro_input};
+
+struct RenderifyTransform;
+
+impl VisitMut for RenderifyTransform {
+ fn visit_expr_mut(&mut self, expr: &mut Expr) {
+ syn::visit_mut::visit_expr_mut(self, expr);
+
+ if let Expr::Try(try_expr) = expr {
+ let inner = &*try_expr.expr;
+ let inner_tokens = inner.to_token_stream();
+
+ // Set the span of the generated `render_route` ident to the `?` token's span,
+ // so that rust-analyzer resolves the `?` position to the `render_route!` macro
+ // instead of the standard Try trait, showing the render_route macro's docs on hover.
+ let q_span = try_expr.question_token.span();
+ let route_ident = proc_macro2::Ident::new("render_route", q_span);
+
+ if let Ok(macro_expr) = syn::parse2::<Expr>(quote::quote! {
+ ::mingling::macros::#route_ident!(#inner_tokens)
+ }) {
+ *expr = macro_expr;
+ }
+ }
+ }
+}
+
+pub(crate) fn renderify_impl(_attr: TokenStream, item: TokenStream) -> TokenStream {
+ let mut input_fn = parse_macro_input!(item as ItemFn);
+ RenderifyTransform.visit_item_fn_mut(&mut input_fn);
+ input_fn.to_token_stream().into()
+}
diff --git a/mingling_macros/src/func/gen_program.rs b/mingling_macros/src/func/gen_program.rs
index de4da06..db82504 100644
--- a/mingling_macros/src/func/gen_program.rs
+++ b/mingling_macros/src/func/gen_program.rs
@@ -342,7 +342,7 @@ fn main() {
) -> Result<::mingling::RenderResult, ::mingling::error::StructuralRendererSerializeError> {
#[allow(unused_imports)]
#(#pathf_uses)*
- match any.member_id {
+ match any.member_id() {
#(#structural_renderer_tokens)*
_ => {
// Non-structural types: render ResultEmpty (which implements
@@ -408,7 +408,7 @@ fn main() {
fn do_comp(any: &::mingling::AnyOutput<Self::Enum>, ctx: &::mingling::ShellContext) -> ::mingling::Suggest {
#[allow(unused_imports)]
#(#pathf_uses)*
- match any.member_id {
+ match any.member_id() {
#(#completion_tokens)*
_ => ::mingling::Suggest::FileCompletion,
}
@@ -442,7 +442,7 @@ fn main() {
}).collect();
quote! {
fn render(any: ::mingling::AnyOutput<Self::Enum>) -> ::mingling::RenderResult {
- match any.member_id {
+ match any.member_id() {
#(#render_arms)*
_ => ::mingling::RenderResult::default(),
}
@@ -494,9 +494,9 @@ fn main() {
fn do_chain(
any: ::mingling::AnyOutput<Self::Enum>,
) -> ::std::pin::Pin<::std::boxed::Box<dyn ::std::future::Future<Output = ::mingling::ChainProcess<Self::Enum>> + ::std::marker::Send>> {
- match any.member_id {
+ match any.member_id() {
#(#chain_arms_async)*
- _ => ::core::panic!("No chain found for type id: {:?}", any.type_id),
+ _ => ::core::panic!("No chain found for type id: {:?}", any.type_id()),
}
}
}
@@ -505,9 +505,9 @@ fn main() {
fn do_chain(
any: ::mingling::AnyOutput<Self::Enum>,
) -> ::mingling::ChainProcess<Self::Enum> {
- match any.member_id {
+ match any.member_id() {
#(#chain_arms_sync)*
- _ => ::core::panic!("No chain found for type id: {:?}", any.type_id),
+ _ => ::core::panic!("No chain found for type id: {:?}", any.type_id()),
}
}
}
@@ -535,7 +535,7 @@ fn main() {
let expanded = quote! {
#pathf_hint
- #[derive(Debug, PartialEq, Eq, Clone)]
+ #[derive(Debug, PartialEq, Eq, Clone, Copy)]
#[repr(#repr_type)]
#[allow(nonstandard_style)]
pub enum #name {
@@ -569,19 +569,19 @@ fn main() {
fn render_help(any: ::mingling::AnyOutput<Self::Enum>) -> ::mingling::RenderResult {
#[allow(unused_imports)]
#(#pathf_uses)*
- match any.member_id {
+ match any.member_id() {
#(#help_tokens)*
_ => ::mingling::RenderResult::default(),
}
}
fn has_renderer(any: &::mingling::AnyOutput<Self::Enum>) -> bool {
- match any.member_id {
+ match any.member_id() {
#(#renderer_exist_tokens)*
_ => false
}
}
fn has_chain(any: &::mingling::AnyOutput<Self::Enum>) -> bool {
- match any.member_id {
+ match any.member_id() {
#(#chain_exist_tokens)*
_ => false
}
diff --git a/mingling_macros/src/func/group.rs b/mingling_macros/src/func/group.rs
index b865913..edb1fe1 100644
--- a/mingling_macros/src/func/group.rs
+++ b/mingling_macros/src/func/group.rs
@@ -133,7 +133,11 @@ pub(crate) fn group_macro(input: TokenStream) -> TokenStream {
#type_use
#alias_use
- impl ::mingling::Grouped<__MinglingProgram> for #type_name {
+ /// SAFETY: This is an internal implementation of the `group!` macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<__MinglingProgram> for #type_name {
fn member_id() -> __MinglingProgram {
__MinglingProgram::#type_name
}
diff --git a/mingling_macros/src/func/pack.rs b/mingling_macros/src/func/pack.rs
index a1a7e6b..7206b8e 100644
--- a/mingling_macros/src/func/pack.rs
+++ b/mingling_macros/src/func/pack.rs
@@ -138,7 +138,11 @@ pub(crate) fn pack(input: TokenStream) -> TokenStream {
}
}
- impl ::mingling::Grouped<#group_name> for #type_name {
+ /// SAFETY: This is an internal implementation of the `pack!` macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<#group_name> for #type_name {
fn member_id() -> #group_name {
#group_name::#type_name
}
diff --git a/mingling_macros/src/func/pack_err.rs b/mingling_macros/src/func/pack_err.rs
index 36e550a..2a318bc 100644
--- a/mingling_macros/src/func/pack_err.rs
+++ b/mingling_macros/src/func/pack_err.rs
@@ -139,8 +139,8 @@ pub(crate) fn pack_err_structural(input: TokenStream) -> TokenStream {
.insert(type_name_str);
let structural_data = quote! {
- impl ::mingling::__private::StructuralDataSealed for #type_name {}
- impl ::mingling::__private::StructuralData for #type_name {}
+ impl ::mingling::__private::StructuralDataSealed<crate::ThisProgram> for #type_name {}
+ impl ::mingling::__private::StructuralData<crate::ThisProgram> for #type_name {}
};
// Generate the struct + impls (same as pack_err! but with Serialize derive + sealed)
diff --git a/mingling_macros/src/func/r_print.rs b/mingling_macros/src/func/r_print.rs
index e81b544..86ea52f 100644
--- a/mingling_macros/src/func/r_print.rs
+++ b/mingling_macros/src/func/r_print.rs
@@ -60,3 +60,68 @@ pub(crate) fn r_println(input: TokenStream) -> TokenStream {
pub(crate) fn r_print(input: TokenStream) -> TokenStream {
expand_print(input, "print")
}
+
+pub(crate) fn r_eprintln(input: TokenStream) -> TokenStream {
+ expand_print(input, "eprintln")
+}
+
+pub(crate) fn r_eprint(input: TokenStream) -> TokenStream {
+ expand_print(input, "eprint")
+}
+
+/// Parsed input for `r_append!`.
+///
+/// Two forms:
+/// - `(dst, src)` — explicit buffer and source
+/// - `(src)` — implicit `__render_result_buffer`
+struct AppendInput {
+ dst: Option<Ident>,
+ src: proc_macro2::TokenStream,
+}
+
+impl Parse for AppendInput {
+ fn parse(input: ParseStream) -> syn::Result<Self> {
+ if input.peek(Ident) && input.peek2(Token![,]) {
+ let dst: Ident = input.parse()?;
+ let _comma: Token![,] = input.parse()?;
+ let src: TokenStream2 = input.parse()?;
+ Ok(AppendInput {
+ dst: Some(dst),
+ src,
+ })
+ } else {
+ let src: TokenStream2 = input.parse()?;
+ Ok(AppendInput { dst: None, src })
+ }
+ }
+}
+
+/// `r_append!` macro: appends the contents of another `RenderResult` to this one.
+///
+/// Two forms:
+/// - `r_append!(dst, src)` — appends `src` into `dst`
+/// - `r_append!(src)` — appends `src` into `__render_result_buffer`
+pub(crate) fn r_append(input: TokenStream) -> TokenStream {
+ let parsed: AppendInput = match syn::parse(input) {
+ Ok(p) => p,
+ Err(e) => return e.to_compile_error().into(),
+ };
+
+ let dst_ident = parsed.dst.clone();
+ let src_tokens = parsed.src;
+
+ let expanded = match dst_ident {
+ Some(dst) => {
+ quote! {
+ #dst.append_other(#src_tokens);
+ }
+ }
+ None => {
+ quote! {
+ __render_result_buffer.append_other(#src_tokens);
+ }
+ }
+ };
+
+ expanded.into()
+}
diff --git a/mingling_macros/src/lib.rs b/mingling_macros/src/lib.rs
index 56ed999..ca3261e 100644
--- a/mingling_macros/src/lib.rs
+++ b/mingling_macros/src/lib.rs
@@ -590,6 +590,56 @@ pub fn route(input: TokenStream) -> TokenStream {
TokenStream::from(expanded)
}
+/// Routes errors to the rendering pipeline instead of the chain pipeline.
+///
+/// This macro is similar to [`route!`] but instead of routing errors through
+/// `Routable::to_chain()` (which returns `ChainProcess`), it routes them
+/// directly to the renderer via `crate::ThisProgram::render(AnyOutput::new(e))`
+/// (which returns `RenderResult`).
+///
+/// This is useful in `#[renderer]` and `#[help]` functions where the return
+/// type is `RenderResult` rather than `ChainProcess`.
+///
+/// # Syntax
+///
+/// ```rust,ignore
+/// render_route!(expr)
+/// ```
+///
+/// Where `expr` is an expression of type `Result<T, E>`.
+///
+/// # Interaction with `#[routeify]`
+///
+/// When `#[routeify]` is used on a `#[renderer]` or `#[help]` function (e.g.
+/// `#[renderer(routeify)]` or `#[help(routeify)]`), every `expr?` is automatically
+/// replaced with `render_route!(expr)` instead of `route!(expr)`.
+///
+/// # Example
+///
+/// ```rust,ignore
+/// use mingling::macros::{renderer, render_route};
+/// use std::io::Write;
+///
+/// #[renderer]
+/// fn render_something(prev: SomeType) -> RenderResult {
+/// let data = render_route!(fetch_data().map_err(|e| ErrorEntry::new(e.to_string())))?;
+/// // ... render data
+/// Ok(RenderResult::new())
+/// }
+/// ```
+#[cfg(feature = "extra_macros")]
+#[proc_macro]
+pub fn render_route(input: TokenStream) -> TokenStream {
+ let expr = parse_macro_input!(input as syn::Expr);
+ let expanded = quote! {
+ match #expr {
+ Ok(r) => r,
+ Err(e) => return <crate::ThisProgram as ::mingling::ProgramCollect>::render(::mingling::AnyOutput::new(e)),
+ }
+ };
+ TokenStream::from(expanded)
+}
+
/// Creates an empty result value wrapped in `ChainProcess` for early return
/// from a chain function.
///
@@ -1293,6 +1343,25 @@ pub fn help(attr: TokenStream, item: TokenStream) -> TokenStream {
help::help_attr(item)
}
+/// Marker attribute for the Mingling lint system.
+///
+/// The content of this attribute is ignored by rustc and reserved for
+/// the mlint tool to interpret. All it does is pass the item through
+/// unchanged.
+///
+/// # Examples
+///
+/// ```rust,ignore
+/// #[mlint(allow(MLINT_SOME_LINT))]
+/// #[mlint(warn(MLINT_SOME_LINT))]
+/// #[mlint(deny(MLINT_SOME_LINT))]
+/// fn some_item() {}
+/// ```
+#[proc_macro_attribute]
+pub fn mlint(_attr: TokenStream, item: TokenStream) -> TokenStream {
+ item
+}
+
/// Extension attribute macro that transforms `expr?` into `route!(expr)`.
///
/// Designed for use with `#[chain(routeify, ...)]` to enable concise error
@@ -1314,6 +1383,32 @@ pub fn routeify(attr: TokenStream, item: TokenStream) -> TokenStream {
extensions::routeify::routeify_impl(attr, item)
}
+/// Extension attribute macro that transforms `expr?` into `render_route!(expr)`.
+///
+/// Designed for use with `#[renderer(renderify, ...)]` or `#[help(renderify, ...)]`
+/// to enable concise error routing in renderer and help functions using the `?`
+/// operator syntax.
+///
+/// Unlike `#[routeify]` which routes errors to the chain pipeline via `route!`,
+/// `#[renderify]` routes errors to the rendering pipeline via `render_route!`,
+/// which matches the `RenderResult` return type of renderer and help functions.
+///
+/// # Example
+///
+/// ```rust,ignore
+/// #[renderer(renderify)]
+/// fn render_greeting(prev: Greeting) -> RenderResult {
+/// let data = load_data()?; // expands to render_route!(load_data())
+/// r_println!("{data}");
+/// Ok(RenderResult::new())
+/// }
+/// ```
+#[cfg(feature = "extra_macros")]
+#[proc_macro_attribute]
+pub fn renderify(attr: TokenStream, item: TokenStream) -> TokenStream {
+ extensions::renderify::renderify_impl(attr, item)
+}
+
/// Wraps a unit-returning function to produce a `RenderResult`.
///
/// The `#[buffer]` attribute macro injects a local `__render_result_buffer`
@@ -1412,6 +1507,102 @@ pub fn r_print(input: TokenStream) -> TokenStream {
func::r_print::r_print(input)
}
+/// Prints text to a `RenderResult` buffer (standard error style), with a trailing newline.
+///
+/// This macro works identically to `r_println!` but conceptually targets
+/// "error output" — it writes into a `RenderResult` buffer with a trailing newline.
+///
+/// # Implicit buffer (inside `#[buffer]` functions)
+///
+/// ```rust,ignore
+/// use mingling::macros::{buffer, r_eprintln};
+///
+/// #[buffer]
+/// fn render() {
+/// r_eprintln!("Error: {}", err_msg);
+/// }
+/// ```
+///
+/// # Explicit buffer
+///
+/// Pass a `RenderResult` variable as the first argument:
+///
+/// ```rust,ignore
+/// use mingling::macros::r_eprintln;
+/// use mingling::RenderResult;
+///
+/// let mut r = RenderResult::new();
+/// r_eprintln!(r, "error: {}", 42);
+/// assert_eq!(&*r, "error: 42\n");
+/// ```
+#[proc_macro]
+pub fn r_eprintln(input: TokenStream) -> TokenStream {
+ func::r_print::r_eprintln(input)
+}
+
+/// Prints text to a `RenderResult` buffer (standard error style), without a trailing newline.
+///
+/// This macro works identically to `r_print!` but conceptually targets
+/// "error output" — it writes into a `RenderResult` buffer without a trailing newline.
+///
+/// # Implicit buffer (inside `#[buffer]` functions)
+///
+/// ```rust,ignore
+/// use mingling::macros::{buffer, r_eprint};
+///
+/// #[buffer]
+/// fn render() {
+/// r_eprint!("Error: ");
+/// r_eprintln!("something went wrong");
+/// }
+/// ```
+///
+/// # Explicit buffer
+///
+/// ```rust,ignore
+/// use mingling::macros::r_eprint;
+/// use mingling::RenderResult;
+///
+/// let mut r = RenderResult::new();
+/// r_eprint!(r, "error: ");
+/// r_eprintln!(r, "42");
+/// assert_eq!(&*r, "error: 42\n");
+/// ```
+#[proc_macro]
+pub fn r_eprint(input: TokenStream) -> TokenStream {
+ func::r_print::r_eprint(input)
+}
+
+/// Appends the contents of one `RenderResult` to another.
+///
+/// # Implicit buffer (inside `#[buffer]` functions)
+///
+/// ```rust,ignore
+/// use mingling::macros::{buffer, r_append};
+///
+/// #[buffer]
+/// fn render() {
+/// let other = make_other_result();
+/// r_append!(other);
+/// }
+/// ```
+///
+/// # Explicit buffer
+///
+/// ```rust,ignore
+/// use mingling::macros::r_append;
+/// use mingling::RenderResult;
+///
+/// let mut dst = RenderResult::new();
+/// let src = RenderResult::from("hello");
+/// r_append!(dst, src);
+/// assert!(!dst.is_empty());
+/// ```
+#[proc_macro]
+pub fn r_append(input: TokenStream) -> TokenStream {
+ func::r_print::r_append(input)
+}
+
/// Derive macro for automatically implementing the `Grouped` trait on a struct.
///
/// The `#[derive(Grouped)]` macro:
diff --git a/mingling_macros/src/systems/structural_data.rs b/mingling_macros/src/systems/structural_data.rs
index 74bcf09..46d7cf8 100644
--- a/mingling_macros/src/systems/structural_data.rs
+++ b/mingling_macros/src/systems/structural_data.rs
@@ -29,8 +29,8 @@ pub(crate) fn derive_structural_data(input: TokenStream) -> TokenStream {
// Users cannot implement StructuralDataSealed manually (it's #[doc(hidden)]),
// so the only way to get StructuralData is through this derive macro.
let expanded = quote! {
- impl ::mingling::__private::StructuralDataSealed for #type_name {}
- impl ::mingling::__private::StructuralData for #type_name {}
+ impl ::mingling::__private::StructuralDataSealed<crate::ThisProgram> for #type_name {}
+ impl ::mingling::__private::StructuralData<crate::ThisProgram> for #type_name {}
};
expanded.into()
@@ -149,8 +149,8 @@ pub(crate) fn pack_structural(input: TokenStream) -> TokenStream {
// StructuralData impl + sealed + registration
let structural_impl = quote! {
- impl ::mingling::__private::StructuralDataSealed for #type_name {}
- impl ::mingling::__private::StructuralData for #type_name {}
+ impl ::mingling::__private::StructuralDataSealed<crate::ThisProgram> for #type_name {}
+ impl ::mingling::__private::StructuralData<crate::ThisProgram> for #type_name {}
};
let expanded = quote! {
@@ -176,7 +176,11 @@ pub(crate) fn pack_structural(input: TokenStream) -> TokenStream {
}
}
- impl ::mingling::Grouped<#program_path> for #type_name {
+ /// SAFETY: This is an internal implementation of the `pack_structural!` macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<#program_path> for #type_name {
fn member_id() -> #program_path {
#program_path::#type_name
}
@@ -297,14 +301,18 @@ pub(crate) fn group_structural(input: TokenStream) -> TokenStream {
#type_use
#alias_use
- impl ::mingling::Grouped<__MinglingProgram> for #type_name {
+ /// SAFETY: This is an internal implementation of the `pack_structural!` macro,
+ /// guaranteeing that the enum value registered by the `register_type!` macro
+ /// is exactly the same as the actual return value,
+ /// which can be confirmed via the `Ident` in the `quote!` block.
+ unsafe impl ::mingling::Grouped<__MinglingProgram> for #type_name {
fn member_id() -> __MinglingProgram {
__MinglingProgram::#type_name
}
}
- impl ::mingling::__private::StructuralDataSealed for #type_name {}
- impl ::mingling::__private::StructuralData for #type_name {}
+ impl ::mingling::__private::StructuralDataSealed<crate::ThisProgram> for #type_name {}
+ impl ::mingling::__private::StructuralData<crate::ThisProgram> for #type_name {}
::mingling::macros::register_type!(#type_name);
}
diff --git a/mingling_pathf/src/pattern_analyzer.rs b/mingling_pathf/src/pattern_analyzer.rs
index 31d6918..5b25c15 100644
--- a/mingling_pathf/src/pattern_analyzer.rs
+++ b/mingling_pathf/src/pattern_analyzer.rs
@@ -48,6 +48,28 @@ pub struct AnalyzeItem {
pub module: String,
/// The name of the item itself, e.g. `"HashMap"`, `"AnalyzeResult"`, etc.
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,
+}
+
+impl AnalyzeItem {
+ /// Creates a local `AnalyzeItem` (not foreign, will be prefixed with the file's module path).
+ pub fn local(module: String, item_name: String) -> Self {
+ Self {
+ module,
+ item_name,
+ is_foreign: false,
+ }
+ }
+
+ /// Creates a foreign `AnalyzeItem` (resolved via `use`, the `module` field is the full import path).
+ pub fn foreign(module: String, item_name: String) -> Self {
+ Self {
+ module,
+ item_name,
+ is_foreign: true,
+ }
+ }
}
/// Collection of analysis results
diff --git a/mingling_pathf/src/patterns/basic_struct.rs b/mingling_pathf/src/patterns/basic_struct.rs
index 09e8e70..9218d65 100644
--- a/mingling_pathf/src/patterns/basic_struct.rs
+++ b/mingling_pathf/src/patterns/basic_struct.rs
@@ -28,20 +28,17 @@ impl AnalyzePattern for BasicStructPattern {
match item {
// Root-level struct
Item::Struct(s) => {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: s.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(String::new(), s.ident.to_string()));
}
// Struct within inline modules
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
for n in nested {
if let syn::Item::Struct(s) = n {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: s.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ s.ident.to_string(),
+ ));
}
}
}
diff --git a/mingling_pathf/src/patterns/chain.rs b/mingling_pathf/src/patterns/chain.rs
index 6393440..2e95db2 100644
--- a/mingling_pathf/src/patterns/chain.rs
+++ b/mingling_pathf/src/patterns/chain.rs
@@ -18,7 +18,7 @@ pub struct ChainPattern;
impl AnalyzePattern for ChainPattern {
fn contains(&self, content: &str) -> bool {
- content.contains("chain]")
+ content.contains("[chain") || content.contains("chain]")
}
fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
@@ -44,10 +44,10 @@ fn collect_from_item(item: &Item, current_mod: &str, items: &mut Vec<AnalyzeItem
match item {
Item::Fn(f) if has_attr(&f.attrs, "chain") => {
let fn_name = f.sig.ident.to_string();
- items.push(AnalyzeItem {
- module: current_mod.to_string(),
- item_name: internal_name(&fn_name),
- });
+ items.push(AnalyzeItem::local(
+ current_mod.to_string(),
+ internal_name(&fn_name),
+ ));
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
diff --git a/mingling_pathf/src/patterns/completion.rs b/mingling_pathf/src/patterns/completion.rs
index 5427b93..cff62e0 100644
--- a/mingling_pathf/src/patterns/completion.rs
+++ b/mingling_pathf/src/patterns/completion.rs
@@ -13,7 +13,7 @@ pub struct CompletionPattern;
impl AnalyzePattern for CompletionPattern {
fn contains(&self, content: &str) -> bool {
- content.contains("completion(")
+ content.contains("completion(") || content.contains("[completion")
}
fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
@@ -37,10 +37,10 @@ fn collect_from_item(item: &Item, current_mod: &str, items: &mut Vec<AnalyzeItem
match item {
Item::Fn(f) if has_attr(&f.attrs, "completion") => {
let fn_name = f.sig.ident.to_string();
- items.push(AnalyzeItem {
- module: current_mod.to_string(),
- item_name: internal_name(&fn_name),
- });
+ items.push(AnalyzeItem::local(
+ current_mod.to_string(),
+ internal_name(&fn_name),
+ ));
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
diff --git a/mingling_pathf/src/patterns/dispatcher.rs b/mingling_pathf/src/patterns/dispatcher.rs
index eaa53f0..cedad9f 100644
--- a/mingling_pathf/src/patterns/dispatcher.rs
+++ b/mingling_pathf/src/patterns/dispatcher.rs
@@ -111,27 +111,18 @@ fn extract_all_types(
// Entry type — always
if let Some(ref entry) = entry_struct {
- items.push(AnalyzeItem {
- module: module.to_string(),
- item_name: entry.clone(),
- });
+ items.push(AnalyzeItem::local(module.to_string(), entry.clone()));
}
// CMD type — always
if let Some(ref cmd) = cmd_struct {
- items.push(AnalyzeItem {
- module: module.to_string(),
- item_name: cmd.clone(),
- });
+ items.push(AnalyzeItem::local(module.to_string(), cmd.clone()));
}
// __internal_dispatcher_* — when configured
if use_dispatch_tree {
let internal_name = format!("__internal_dispatcher_{}", snake_case(&cmd_name));
- items.push(AnalyzeItem {
- module: module.to_string(),
- item_name: internal_name,
- });
+ items.push(AnalyzeItem::local(module.to_string(), internal_name));
}
items
diff --git a/mingling_pathf/src/patterns/dispatcher_clap.rs b/mingling_pathf/src/patterns/dispatcher_clap.rs
index 0a249b4..25a7093 100644
--- a/mingling_pathf/src/patterns/dispatcher_clap.rs
+++ b/mingling_pathf/src/patterns/dispatcher_clap.rs
@@ -61,10 +61,7 @@ impl AnalyzePattern for DispatcherClapPattern {
Item::Struct(s) if has_attr(&s.attrs, "dispatcher_clap") => {
// Entry type (struct name) — always
let entry_name = s.ident.to_string();
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: entry_name.clone(),
- });
+ items.push(AnalyzeItem::local(String::new(), entry_name.clone()));
// Parse the attribute to extract CMD, error, and help info
if let Some(attr) = s.attrs.iter().find(|a| {
@@ -79,18 +76,12 @@ impl AnalyzePattern for DispatcherClapPattern {
// CMD type — always
if let Some(ref cmd) = parsed.cmd_type {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: cmd.clone(),
- });
+ items.push(AnalyzeItem::local(String::new(), cmd.clone()));
}
// Error type — if error = TypeName
if let Some(ref err) = parsed.error_type {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: err.clone(),
- });
+ items.push(AnalyzeItem::local(String::new(), err.clone()));
}
// Help internal struct — if help = true
@@ -100,10 +91,7 @@ impl AnalyzePattern for DispatcherClapPattern {
let help_fn = format!("__{}_help", just_fmt::snake_case!(cmd));
let help_struct =
format!("__internal_help_{}", just_fmt::snake_case!(&help_fn));
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: help_struct,
- });
+ items.push(AnalyzeItem::local(String::new(), help_struct));
}
// __internal_dispatcher_* — when configured
@@ -114,10 +102,7 @@ impl AnalyzePattern for DispatcherClapPattern {
"__internal_dispatcher_{}",
just_fmt::snake_case!(cmd_name)
);
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: internal_name,
- });
+ items.push(AnalyzeItem::local(String::new(), internal_name));
}
}
}
@@ -128,10 +113,10 @@ impl AnalyzePattern for DispatcherClapPattern {
&& has_attr(&s.attrs, "dispatcher_clap")
{
let entry_name = s.ident.to_string();
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: entry_name.clone(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ entry_name.clone(),
+ ));
if let Some(attr) = s.attrs.iter().find(|a| {
a.path()
@@ -145,17 +130,17 @@ impl AnalyzePattern for DispatcherClapPattern {
let parsed = parse_dispatcher_clap_args(&args_str);
if let Some(ref cmd) = parsed.cmd_type {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: cmd.clone(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ cmd.clone(),
+ ));
}
if let Some(ref err) = parsed.error_type {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: err.clone(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ err.clone(),
+ ));
}
// Help internal struct — same naming rule as root level
@@ -168,10 +153,10 @@ impl AnalyzePattern for DispatcherClapPattern {
"__internal_help_{}",
just_fmt::snake_case!(&help_fn)
);
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: help_struct,
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ help_struct,
+ ));
}
// __internal_dispatcher_* — when configured
@@ -182,10 +167,10 @@ impl AnalyzePattern for DispatcherClapPattern {
"__internal_dispatcher_{}",
just_fmt::snake_case!(cmd_name)
);
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: internal_name,
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ internal_name,
+ ));
}
}
}
diff --git a/mingling_pathf/src/patterns/group.rs b/mingling_pathf/src/patterns/group.rs
index 0e4b50d..0f9ecff 100644
--- a/mingling_pathf/src/patterns/group.rs
+++ b/mingling_pathf/src/patterns/group.rs
@@ -2,7 +2,10 @@
//! extracts the type name or alias defined within them.
//! This is used to track type groups for code generation or analysis.
+use std::collections::HashMap;
+
use syn::Item;
+use syn::UseTree;
use crate::pattern_analyzer::{AnalyzeItem, AnalyzePattern};
@@ -25,6 +28,9 @@ impl AnalyzePattern for GroupPattern {
return Vec::new();
};
+ // Collect `use` imports at the file level
+ let imports = collect_use_imports(&syntax.items);
+
let mut items = Vec::new();
for item in &syntax.items {
@@ -37,15 +43,15 @@ impl AnalyzePattern for GroupPattern {
if macro_name != "group" && macro_name != "group_structural" {
continue;
}
- if let Some(name) = extract_group_name(&m.mac.tokens) {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: name,
- });
+ if let Some(analyze_item) = extract_group_item(&m.mac.tokens, &imports, "") {
+ items.push(analyze_item);
}
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
+ // Collect `use` imports inside this module
+ let inner_imports = collect_use_imports(nested);
+
for n in nested {
if let Item::Macro(m) = n {
let Some(last) = m.mac.path.segments.last() else {
@@ -55,11 +61,12 @@ impl AnalyzePattern for GroupPattern {
if macro_name != "group" && macro_name != "group_structural" {
continue;
}
- if let Some(name) = extract_group_name(&m.mac.tokens) {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: name,
- });
+ if let Some(analyze_item) = extract_group_item(
+ &m.mac.tokens,
+ &inner_imports,
+ &item_mod.ident.to_string(),
+ ) {
+ items.push(analyze_item);
}
}
}
@@ -73,6 +80,95 @@ impl AnalyzePattern for GroupPattern {
}
}
+/// Extract an `AnalyzeItem` from the macro invocation tokens.
+///
+/// If the name matches a `use` import, resolves to the full foreign path.
+/// For the `Alias = path::Type` form, returns a local item (the alias exists in-crate).
+fn extract_group_item(
+ tokens: &proc_macro2::TokenStream,
+ imports: &HashMap<String, (String, String)>,
+ current_mod: &str,
+) -> Option<AnalyzeItem> {
+ let name = extract_group_name(tokens)?;
+ let is_aliased = has_equals_sign(tokens);
+
+ if is_aliased {
+ // `group!(Alias = path::Type)` — alias lives in-crate
+ Some(AnalyzeItem::local(current_mod.to_string(), name))
+ } else if let Some((module, _)) = imports.get(&name) {
+ // `group!(TypeName)` where TypeName is imported via `use` — foreign
+ Some(AnalyzeItem::foreign(module.clone(), name))
+ } else {
+ // `group!(LocalType)` — local type
+ Some(AnalyzeItem::local(current_mod.to_string(), name))
+ }
+}
+
+/// Collect `use` imports from a list of top-level items.
+///
+/// Returns a map of `short_name → (module_path, short_name)`.
+/// e.g. `use cargo_metadata::CompilerMessage;` → `"CompilerMessage" → ("cargo_metadata", "CompilerMessage")`
+fn collect_use_imports(items: &[syn::Item]) -> HashMap<String, (String, String)> {
+ let mut map = HashMap::new();
+ for item in items {
+ if let Item::Use(use_item) = item {
+ // Only handle non-`pub use` (regular imports)
+ collect_from_use_tree(&use_item.tree, "", &mut map);
+ }
+ }
+ map
+}
+
+/// Recursively traverse a `UseTree` and collect named imports.
+fn collect_from_use_tree(
+ tree: &UseTree,
+ prefix: &str,
+ map: &mut HashMap<String, (String, String)>,
+) {
+ match tree {
+ UseTree::Name(name) => {
+ let module = prefix.to_string();
+ let alias = name.ident.to_string();
+ map.entry(alias).or_insert((module, name.ident.to_string()));
+ }
+ UseTree::Path(use_path) => {
+ let new_prefix = if prefix.is_empty() {
+ use_path.ident.to_string()
+ } else {
+ format!("{}::{}", prefix, use_path.ident)
+ };
+ collect_from_use_tree(&use_path.tree, &new_prefix, map);
+ }
+ UseTree::Rename(rename) => {
+ let module = prefix.to_string();
+ let alias = rename.ident.to_string();
+ map.entry(alias)
+ .or_insert((module, rename.ident.to_string()));
+ }
+ UseTree::Glob(_) => {
+ // `use path::*;` — skip glob imports
+ }
+ UseTree::Group(group) => {
+ for item in &group.items {
+ collect_from_use_tree(item, prefix, map);
+ }
+ }
+ }
+}
+
+/// Check whether the macro tokens contain `=`, indicating aliased form.
+fn has_equals_sign(tokens: &proc_macro2::TokenStream) -> bool {
+ let stream = tokens.clone();
+ for token in stream {
+ if let proc_macro2::TokenTree::Punct(p) = token
+ && p.as_char() == '='
+ {
+ return true;
+ }
+ }
+ false
+}
+
/// Extract the alias / type name from the arguments of `group!`.
///
/// - `group!(ParseIntError)` → `ParseIntError`
diff --git a/mingling_pathf/src/patterns/grouped_derive.rs b/mingling_pathf/src/patterns/grouped_derive.rs
index 9522c1f..56a87f9 100644
--- a/mingling_pathf/src/patterns/grouped_derive.rs
+++ b/mingling_pathf/src/patterns/grouped_derive.rs
@@ -30,38 +30,29 @@ impl AnalyzePattern for GroupedDerivePattern {
for item in &syntax.items {
match item {
Item::Struct(s) if has_grouped_derive(&s.attrs) => {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: s.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(String::new(), s.ident.to_string()));
}
Item::Enum(e) if has_grouped_derive(&e.attrs) => {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: e.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(String::new(), e.ident.to_string()));
}
Item::Union(u) if has_grouped_derive(&u.attrs) => {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: u.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(String::new(), u.ident.to_string()));
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
for n in nested {
match n {
Item::Struct(s) if has_grouped_derive(&s.attrs) => {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: s.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ s.ident.to_string(),
+ ));
}
Item::Enum(e) if has_grouped_derive(&e.attrs) => {
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: e.ident.to_string(),
- });
+ items.push(AnalyzeItem::local(
+ item_mod.ident.to_string(),
+ e.ident.to_string(),
+ ));
}
_ => {}
}
diff --git a/mingling_pathf/src/patterns/help.rs b/mingling_pathf/src/patterns/help.rs
index 628f4ac..85793c3 100644
--- a/mingling_pathf/src/patterns/help.rs
+++ b/mingling_pathf/src/patterns/help.rs
@@ -13,7 +13,7 @@ pub struct HelpPattern;
impl AnalyzePattern for HelpPattern {
fn contains(&self, content: &str) -> bool {
- content.contains("help]")
+ content.contains("[help") || content.contains("help]")
}
fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
@@ -37,10 +37,10 @@ fn collect_from_item(item: &Item, current_mod: &str, items: &mut Vec<AnalyzeItem
match item {
Item::Fn(f) if has_attr(&f.attrs, "help") => {
let fn_name = f.sig.ident.to_string();
- items.push(AnalyzeItem {
- module: current_mod.to_string(),
- item_name: internal_name(&fn_name),
- });
+ items.push(AnalyzeItem::local(
+ current_mod.to_string(),
+ internal_name(&fn_name),
+ ));
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
diff --git a/mingling_pathf/src/patterns/pack.rs b/mingling_pathf/src/patterns/pack.rs
index c80fb65..76597ba 100644
--- a/mingling_pathf/src/patterns/pack.rs
+++ b/mingling_pathf/src/patterns/pack.rs
@@ -18,7 +18,10 @@ pub struct PackPattern;
impl AnalyzePattern for PackPattern {
fn contains(&self, content: &str) -> bool {
- content.contains("pack!") || content.contains("pack_err!")
+ content.contains("pack!")
+ || content.contains("pack_err!")
+ || content.contains("pack_structural!")
+ || content.contains("pack_err_structural!")
}
fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
@@ -33,10 +36,7 @@ impl AnalyzePattern for PackPattern {
// Top-level macro calls
Item::Macro(m) => {
if let Some(name) = try_extract_pack_name(m) {
- items.push(AnalyzeItem {
- module: String::new(),
- item_name: name,
- });
+ items.push(AnalyzeItem::local(String::new(), name));
}
}
// Macro calls inside inline modules
@@ -46,10 +46,7 @@ impl AnalyzePattern for PackPattern {
if let Item::Macro(m) = n
&& let Some(name) = try_extract_pack_name(m)
{
- items.push(AnalyzeItem {
- module: item_mod.ident.to_string(),
- item_name: name,
- });
+ items.push(AnalyzeItem::local(item_mod.ident.to_string(), name));
}
}
}
diff --git a/mingling_pathf/src/patterns/renderer.rs b/mingling_pathf/src/patterns/renderer.rs
index c2e9ca9..153e603 100644
--- a/mingling_pathf/src/patterns/renderer.rs
+++ b/mingling_pathf/src/patterns/renderer.rs
@@ -13,7 +13,7 @@ pub struct RendererPattern;
impl AnalyzePattern for RendererPattern {
fn contains(&self, content: &str) -> bool {
- content.contains("renderer]")
+ content.contains("[renderer") || content.contains("renderer]")
}
fn analyze(&self, content: &str) -> Vec<AnalyzeItem> {
@@ -37,10 +37,10 @@ fn collect_from_item(item: &Item, current_mod: &str, items: &mut Vec<AnalyzeItem
match item {
Item::Fn(f) if has_attr(&f.attrs, "renderer") => {
let fn_name = f.sig.ident.to_string();
- items.push(AnalyzeItem {
- module: current_mod.to_string(),
- item_name: internal_name(&fn_name),
- });
+ items.push(AnalyzeItem::local(
+ current_mod.to_string(),
+ internal_name(&fn_name),
+ ));
}
Item::Mod(item_mod) => {
if let Some((_, nested)) = &item_mod.content {
diff --git a/mingling_pathf/src/type_mapping_builder.rs b/mingling_pathf/src/type_mapping_builder.rs
index 0965b47..f6f57a5 100644
--- a/mingling_pathf/src/type_mapping_builder.rs
+++ b/mingling_pathf/src/type_mapping_builder.rs
@@ -39,7 +39,10 @@ pub fn analyze_and_build_type_mapping_for(
};
for ai in analyze_items {
- let full_path = if ai.module.is_empty() {
+ 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)