diff options
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(¯o_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(¯o_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(¤t_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) |
