From f3aee4c7c8a33cf5755f83bcce985504e3ca6ee7 Mon Sep 17 00:00:00 2001 From: winlogon Date: Tue, 18 Nov 2025 13:49:46 +0100 Subject: [PATCH 1/4] feat: initial implementation of component!() macro --- src/lib.rs | 90 +++++++++++++++++++++++++++ src/macros.rs | 166 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 src/macros.rs diff --git a/src/lib.rs b/src/lib.rs index 052523e..c0319d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -94,6 +94,7 @@ #![forbid(unsafe_code)] mod colors; +mod macros; pub mod parsing; #[cfg(feature = "minimessage")] @@ -503,6 +504,26 @@ pub enum TextDecoration { Obfuscated, } +impl FromStr for TextDecoration { + type Err = (); + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("bold") { + Ok(TextDecoration::Bold) + } else if s.eq_ignore_ascii_case("italic") { + Ok(TextDecoration::Italic) + } else if s.eq_ignore_ascii_case("underlined") { + Ok(TextDecoration::Underlined) + } else if s.eq_ignore_ascii_case("strikethrough") { + Ok(TextDecoration::Strikethrough) + } else if s.eq_ignore_ascii_case("obfuscated") { + Ok(TextDecoration::Obfuscated) + } else { + Err(()) + } + } +} + /// Style properties for merging (unused in current implementation) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum StyleMerge { @@ -965,4 +986,73 @@ mod tests { let component: Component = serde_json::from_str(raw_json).unwrap(); println!("Message: {component:#?}"); } + + #[test] + fn test_component_macro() { + let component = component!(text: "Hello, ", { + color: yellow, + append: (component!(text: "World!", { + color: white, + decoration: bold & true, + })), + }); + + let expected = Component::text("Hello, ") + .color(Some(Color::Named(NamedColor::Yellow))) + .append( + Component::text("World!") + .color(Some(Color::Named(NamedColor::White))) + .decoration(TextDecoration::Bold, Some(true)), + ); + + assert_eq!(component, expected); + } + + #[test] + fn test_component_macro_new_syntax() { + let component_named = component!(text: "hello", { + color: red, + }); + let expected_named = Component::text("hello").color(Some(Color::Named(NamedColor::Red))); + assert_eq!(component_named, expected_named); + + let component_hex = component!(text: "world", { + color: #123456, + }); + let expected_hex = Component::text("world").color(Some(Color::Hex("#123456".to_string()))); + assert_eq!(component_hex, expected_hex); + + let component_mixed = component!(text: "test", { + color: blue, + append: (Component::text("more")), + }); + let expected_mixed = Component::text("test") + .color(Some(Color::Named(NamedColor::Blue))) + .append(Component::text("more")); + assert_eq!(component_mixed, expected_mixed); + + let component_deco = component!(text: "decorated", { + decoration: bold & true, + }); + let expected_deco = + Component::text("decorated").decoration(TextDecoration::Bold, Some(true)); + assert_eq!(component_deco, expected_deco); + + let component_full = component!(text: "full", { + font: "uniform", + insertion: "inserted", + click_event: run_command { command: "command".to_string() }, + hover_event: show_text { component!(text: "hover") }, + }); + let expected_full = Component::text("full") + .font(Some("uniform".to_string())) + .insertion(Some("inserted".to_string())) + .click_event(Some(ClickEvent::RunCommand { + command: "command".to_string(), + })) + .hover_event(Some(HoverEvent::ShowText { + value: component!(text: "hover"), + })); + assert_eq!(component_full, expected_full); + } } diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..7d01225 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,166 @@ +//! Macros for creating components. + +#[doc(hidden)] +#[macro_export] +macro_rules! __click_event_from_snake { + (open_url, { $($body:tt)* }) => { $crate::ClickEvent::OpenUrl { $($body)* } }; + (open_file, { $($body:tt)* }) => { $crate::ClickEvent::OpenFile { $($body)* } }; + (run_command, { $($body:tt)* }) => { $crate::ClickEvent::RunCommand { $($body)* } }; + (suggest_command, { $($body:tt)* }) => { $crate::ClickEvent::SuggestCommand { $($body)* } }; + (change_page, { $($body:tt)* }) => { $crate::ClickEvent::ChangePage { $($body)* } }; + (copy_to_clipboard, { $($body:tt)* }) => { $crate::ClickEvent::CopyToClipboard { $($body)* } }; +} + +#[doc(hidden)] +#[macro_export] +macro_rules! __hover_event_from_snake { + (show_text, { $($body:tt)* }) => { $crate::HoverEvent::ShowText { value: $($body)* } }; + (show_item, { $($body:tt)* }) => { $crate::HoverEvent::ShowItem { $($body)* } }; + (show_entity, { $($body:tt)* }) => { $crate::HoverEvent::ShowEntity { $($body)* } }; +} + +#[macro_export] +/// Creates a component in a declarative way. +/// +/// # Examples +/// +/// ``` +/// use kyori_component_json::{component, ClickEvent, Color, Component, NamedColor, TextDecoration}; +/// +/// let component = component!(text: "Hello, ", { +/// color: yellow, +/// append: (component!(text: "World!", { +/// color: white, +/// decoration: bold & true, +/// })), +/// font: "uniform", +/// insertion: "inserted text", +/// click_event: run_command { command: "/say hi".to_string() }, +/// }); +/// +/// let component2 = component!(text: "hello world", { +/// color: #037429, +/// }); +/// ``` +macro_rules! component { + (text: $text:expr) => { + $crate::Component::text($text) + }; + + (text: $text:expr, { $($body:tt)* }) => { + { + let component = $crate::Component::text($text); + component!(@munch component, $($body)*) + } + }; + + // Muncher syntax + (@munch $comp:ident, ) => { $comp }; + + // Munch color: yellow + (@munch $comp:ident, color: $color:ident, $($rest:tt)*) => { + { + let comp = $comp.color(Some($crate::Color::Named(stringify!($color).parse().unwrap()))); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, color: $color:ident) => { + $comp.color(Some($crate::Color::Named(stringify!($color).parse().unwrap()))) + }; + + // Munch color: #FFFFFF + (@munch $comp:ident, color: #$hex:literal, $($rest:tt)*) => { + { + let comp = $comp.color(Some(stringify!(#$hex).replace(" ", "").parse().unwrap())); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, color: #$hex:literal) => { + $comp.color(Some(stringify!(#$hex).replace(" ", "").parse().unwrap())) + }; + + // Munch decoration: bold & true + (@munch $comp:ident, decoration: $deco:ident & $state:expr, $($rest:tt)*) => { + { + let deco = stringify!($deco).parse().unwrap(); + let comp = $comp.decoration(deco, Some($state)); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, decoration: $deco:ident & $state:expr) => { + { + let deco = stringify!($deco).parse().unwrap(); + $comp.decoration(deco, Some($state)) + } + }; + + // Munch font: "uniform" + (@munch $comp:ident, font: $value:literal, $($rest:tt)*) => { + { + let comp = $comp.font(Some($value.to_string())); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, font: $value:literal) => { + $comp.font(Some($value.to_string())) + }; + + // Munch insertion: "text" + (@munch $comp:ident, insertion: $value:literal, $($rest:tt)*) => { + { + let comp = $comp.insertion(Some($value.to_string())); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, insertion: $value:literal) => { + $comp.insertion(Some($value.to_string())) + }; + + // Munch click_event: run_command { ... } + (@munch $comp:ident, click_event: $type:ident { $($body:tt)* }, $($rest:tt)*) => { + { + let event = $crate::__click_event_from_snake!($type, { $($body)* }); + let comp = $comp.click_event(Some(event)); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, click_event: $type:ident { $($body:tt)* }) => { + { + let event = $crate::__click_event_from_snake!($type, { $($body)* }); + $comp.click_event(Some(event)) + } + }; + + // Munch hover_event: show_text { ... } + (@munch $comp:ident, hover_event: $type:ident { $($body:tt)* }, $($rest:tt)*) => { + { + let event = $crate::__hover_event_from_snake!($type, { $($body)* }); + let comp = $comp.hover_event(Some(event)); + component!(@munch comp, $($rest)*) + } + }; + + (@munch $comp:ident, hover_event: $type:ident { $($body:tt)* }) => { + { + let event = $crate::__hover_event_from_snake!($type, { $($body)* }); + $comp.hover_event(Some(event)) + } + }; + + // Munch other fields (legacy value format) + (@munch $comp:ident, $field:ident : ($($value:expr),*), $($rest:tt)*) => { + { + let comp = $comp.$field($($value),*); + component!(@munch comp, $($rest)*) + } + }; + (@munch $comp:ident, $field:ident : ($($value:expr),*)) => { + $comp.$field($($value),*) + }; +} From bb0b46720ef6c048942a2498b23a194096cb4128 Mon Sep 17 00:00:00 2001 From: winlogon Date: Wed, 19 Nov 2025 01:04:43 +0100 Subject: [PATCH 2/4] chore: add newline to the end of Cargo.toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4868750..a6bde20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,4 +14,4 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.140" [features] -minimessage = [] \ No newline at end of file +minimessage = [] From debd590685da264a6add7d683b91cd30a90112a4 Mon Sep 17 00:00:00 2001 From: winlogon Date: Wed, 19 Nov 2025 01:05:36 +0100 Subject: [PATCH 3/4] docs: change MiniMessage link reference to papermc.io --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 842d2ff..62f8e0a 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ let json = serde_json::to_string(&message).unwrap(); ### MiniMessage support (Optional) -This library includes experimental support for parsing and serializing MiniMessage strings, a simplified [markup format](https://docs.advntr.dev/minimessage/index.html). This feature is disabled by default. +This library includes experimental support for parsing and serializing MiniMessage strings, a simplified [markup format](https://docs.papermc.io/adventure/minimessage/). This feature is disabled by default. To enable it, add the `minimessage` feature to your `Cargo.toml`: From ae652c3cbc9bb9c2c83688a7f20c3c9d77945da5 Mon Sep 17 00:00:00 2001 From: winlogon Date: Thu, 20 Nov 2025 15:48:50 +0100 Subject: [PATCH 4/4] docs: add documentation for internal component! macro --- src/macros.rs | 71 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/src/macros.rs b/src/macros.rs index 7d01225..c36717d 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,7 +1,13 @@ //! Macros for creating components. +//! +//! This module provides the `component!` macro, which offers a declarative way to construct +//! Kyori `Component` objects. It simplifies the process of creating complex components +//! by allowing properties to be specified in a more readable, attribute-like syntax. #[doc(hidden)] #[macro_export] +/// Helper macro to convert snake_case identifiers to the corresponding `ClickEvent` enum variants. +/// This allows for a more natural syntax within the `component!` macro for defining click events. macro_rules! __click_event_from_snake { (open_url, { $($body:tt)* }) => { $crate::ClickEvent::OpenUrl { $($body)* } }; (open_file, { $($body:tt)* }) => { $crate::ClickEvent::OpenFile { $($body)* } }; @@ -13,6 +19,8 @@ macro_rules! __click_event_from_snake { #[doc(hidden)] #[macro_export] +/// Helper macro to convert snake_case identifiers to the corresponding `HoverEvent` enum variants. +/// Similar to `__click_event_from_snake!`, this simplifies the syntax for defining hover events. macro_rules! __hover_event_from_snake { (show_text, { $($body:tt)* }) => { $crate::HoverEvent::ShowText { value: $($body)* } }; (show_item, { $($body:tt)* }) => { $crate::HoverEvent::ShowItem { $($body)* } }; @@ -22,6 +30,15 @@ macro_rules! __hover_event_from_snake { #[macro_export] /// Creates a component in a declarative way. /// +/// The `component!` macro supports two primary forms: +/// 1. `component!(text: "Hello")`: Creates a simple text component. +/// 2. `component!(text: "Hello", { ... properties ... })`: Creates a text component +/// and then applies various properties to it, such as color, decorations, events, +/// and appended components. +/// +/// The macro uses an internal "muncher" pattern (`@munch` rules) to iteratively process +/// the provided properties. This allows for a flexible order of properties. +/// /// # Examples /// /// ``` @@ -43,45 +60,59 @@ macro_rules! __hover_event_from_snake { /// }); /// ``` macro_rules! component { + // Base case: Creates a simple text component without additional properties. (text: $text:expr) => { $crate::Component::text($text) }; + // Entry point for components with properties: + // Initializes a base text component and then delegates to the internal `@munch` rules + // to process the remaining properties iteratively. (text: $text:expr, { $($body:tt)* }) => { { let component = $crate::Component::text($text); + // Start the muncher with the initial component and all properties. component!(@munch component, $($body)*) } }; - // Muncher syntax + // --- Muncher Rules (@munch) --- + // The muncher pattern works by repeatedly matching and consuming one property + // at a time, modifying the `comp` (Component) variable, and then recursively + // calling itself with the remaining properties (`$rest`). + + // Base case for the muncher: When no more properties are left, return the + // accumulated component. (@munch $comp:ident, ) => { $comp }; - // Munch color: yellow + // Rule for named colors (e.g., `color: yellow`): + // Parses the color identifier, applies it to the component, and continues munching. (@munch $comp:ident, color: $color:ident, $($rest:tt)*) => { { let comp = $comp.color(Some($crate::Color::Named(stringify!($color).parse().unwrap()))); component!(@munch comp, $($rest)*) } }; - + // Variant for named colors when it's the last property. (@munch $comp:ident, color: $color:ident) => { $comp.color(Some($crate::Color::Named(stringify!($color).parse().unwrap()))) }; - // Munch color: #FFFFFF + // Rule for hex colors (e.g., `color: #FFFFFF`): + // Parses the hex code, applies it, and continues munching. (@munch $comp:ident, color: #$hex:literal, $($rest:tt)*) => { { let comp = $comp.color(Some(stringify!(#$hex).replace(" ", "").parse().unwrap())); component!(@munch comp, $($rest)*) } }; - + // Variant for hex colors when it's the last property. (@munch $comp:ident, color: #$hex:literal) => { $comp.color(Some(stringify!(#$hex).replace(" ", "").parse().unwrap())) }; - // Munch decoration: bold & true + // Rule for text decorations (e.g., `decoration: bold & true`): + // Parses the decoration and its state, applies it, and continues munching. (@munch $comp:ident, decoration: $deco:ident & $state:expr, $($rest:tt)*) => { { let deco = stringify!($deco).parse().unwrap(); @@ -89,7 +120,7 @@ macro_rules! component { component!(@munch comp, $($rest)*) } }; - + // Variant for decorations when it's the last property. (@munch $comp:ident, decoration: $deco:ident & $state:expr) => { { let deco = stringify!($deco).parse().unwrap(); @@ -97,31 +128,35 @@ macro_rules! component { } }; - // Munch font: "uniform" + // Rule for font property (e.g., `font: "uniform"`): + // Applies the font string and continues munching. (@munch $comp:ident, font: $value:literal, $($rest:tt)*) => { { let comp = $comp.font(Some($value.to_string())); component!(@munch comp, $($rest)*) } }; - + // Variant for font when it's the last property. (@munch $comp:ident, font: $value:literal) => { $comp.font(Some($value.to_string())) }; - // Munch insertion: "text" + // Rule for insertion property (e.g., `insertion: "text"`): + // Applies the insertion string and continues munching. (@munch $comp:ident, insertion: $value:literal, $($rest:tt)*) => { { let comp = $comp.insertion(Some($value.to_string())); component!(@munch comp, $($rest)*) } }; - + // Variant for insertion when it's the last property. (@munch $comp:ident, insertion: $value:literal) => { $comp.insertion(Some($value.to_string())) }; - // Munch click_event: run_command { ... } + // Rule for click events (e.g., `click_event: run_command { command: "..." }`): + // Uses the `__click_event_from_snake!` helper to construct the `ClickEvent`, + // applies it, and continues munching. (@munch $comp:ident, click_event: $type:ident { $($body:tt)* }, $($rest:tt)*) => { { let event = $crate::__click_event_from_snake!($type, { $($body)* }); @@ -129,7 +164,7 @@ macro_rules! component { component!(@munch comp, $($rest)*) } }; - + // Variant for click events when it's the last property. (@munch $comp:ident, click_event: $type:ident { $($body:tt)* }) => { { let event = $crate::__click_event_from_snake!($type, { $($body)* }); @@ -137,7 +172,9 @@ macro_rules! component { } }; - // Munch hover_event: show_text { ... } + // Rule for hover events (e.g., `hover_event: show_text { component!(...) }`): + // Uses the `__hover_event_from_snake!` helper to construct the `HoverEvent`, + // applies it, and continues munching. (@munch $comp:ident, hover_event: $type:ident { $($body:tt)* }, $($rest:tt)*) => { { let event = $crate::__hover_event_from_snake!($type, { $($body)* }); @@ -145,7 +182,7 @@ macro_rules! component { component!(@munch comp, $($rest)*) } }; - + // Variant for hover events when it's the last property. (@munch $comp:ident, hover_event: $type:ident { $($body:tt)* }) => { { let event = $crate::__hover_event_from_snake!($type, { $($body)* }); @@ -153,13 +190,15 @@ macro_rules! component { } }; - // Munch other fields (legacy value format) + // Generic rule for other fields (legacy or less common, e.g., `append: (component!(...))`). + // This allows calling methods directly on the component. (@munch $comp:ident, $field:ident : ($($value:expr),*), $($rest:tt)*) => { { let comp = $comp.$field($($value),*); component!(@munch comp, $($rest)*) } }; + // Variant for generic fields when it's the last property. (@munch $comp:ident, $field:ident : ($($value:expr),*)) => { $comp.$field($($value),*) };