From 8be0b61e88dd76c9545d873924aa9a8b504eb093 Mon Sep 17 00:00:00 2001 From: suprohub <125716028+suprohub@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:35:02 +0300 Subject: [PATCH 1/3] Lifetime refactor & crate update (#1) * AI Draft * AI v2 * Micro optimizato * Reformat code * Update readme * Update for using 'a instead of 'static * Update code * Add more tests * Syntax sugar * remove all minimessage stuff * Add databake * Update ver * No minimessage * Docs * Docs again --- Cargo.lock | 259 ++++++++++++++++++++++++++------ Cargo.toml | 16 +- README.md | 2 +- examples/main.rs | 53 ++++--- examples/nbt.rs | 8 +- examples/serde.rs | 6 +- examples/snbt.rs | 4 +- src/content.rs | 136 +++++++++-------- src/custom.rs | 41 +++-- src/fmt.rs | 54 +++---- src/format.rs | 63 ++++---- src/interactivity.rs | 121 ++++++++++----- src/lib.rs | 348 +++++++++++++++++++++++++++++++++---------- src/nbt.rs | 52 +++---- src/parse/mod.rs | 56 +++---- src/parse/nbt.rs | 12 +- src/resolving.rs | 141 ++++++++++++++---- src/translation.rs | 43 +++--- 18 files changed, 982 insertions(+), 433 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 104039f..a52e124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,21 +25,21 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -49,9 +49,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.59" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -76,9 +76,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -120,6 +120,63 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "databake" +version = "0.3.0" +source = "git+https://github.com/suprohub/databake#37cb31e7d616bef163d39d8412be71bc33e2950f" +dependencies = [ + "databake-derive", + "proc-macro2", + "quote", + "uuid", +] + +[[package]] +name = "databake-derive" +version = "0.3.0" +source = "git+https://github.com/suprohub/databake#37cb31e7d616bef163d39d8412be71bc33e2950f" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -142,12 +199,42 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -173,9 +260,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -213,14 +300,20 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "indexmap" -version = "2.13.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -233,11 +326,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -249,21 +343,21 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "miniz_oxide" @@ -290,6 +384,40 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "ownable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be13fa10c77ab9e9dc89fb30d6727f40d740ff2caf256dc28fc1a99efcf585d" +dependencies = [ + "ownable-core", + "ownable-macro", +] + +[[package]] +name = "ownable-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc3131271b6bf6f636edd8d1c0762221650c28c37cfe61c151e46ef9b020d38" + +[[package]] +name = "ownable-macro" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5dc3663cb539af1af233d217a85ff56330c7b212b523128fcd9a5b4bd542ea" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "prettyplease" version = "0.2.37" @@ -326,9 +454,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom", @@ -337,9 +465,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rustc-hash" @@ -400,9 +528,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -413,9 +541,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" @@ -464,6 +592,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "supports-hyperlinks" version = "3.2.0" @@ -481,13 +627,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "text_components" version = "0.1.7" dependencies = [ "chrono", "colored", + "databake", "heck", + "memchr", + "ownable", "proc-macro2", "quote", "rand", @@ -495,6 +655,7 @@ dependencies = [ "serde", "serde_json", "simdnbt", + "smallvec", "supports-hyperlinks", "uuid", ] @@ -533,9 +694,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom", "js-sys", @@ -545,11 +706,11 @@ dependencies = [ [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -558,14 +719,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -576,9 +737,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -586,9 +747,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -599,9 +760,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -717,6 +878,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 141e4b0..e21d663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ categories = [ custom = [] nbt = ["dep:simdnbt"] serde = ["dep:serde"] +ownable = ["dep:ownable"] +databake = ["dep:databake"] build = [ "dep:heck", "dep:proc-macro2", @@ -36,17 +38,21 @@ rand = { version = "0.10", default-features = false, features = [ "std", "thread_rng", ] } -serde = { version = "1.0.228", features = ["derive"], optional = true } +serde = { version = "1.0", features = ["derive"], optional = true } simdnbt = { version = "0.10", optional = true } # Build dependencies -heck = { version = "0.5.0", optional = true } +heck = { version = "0.5", optional = true } proc-macro2 = { version = "1.0", optional = true } quote = { version = "1.0", optional = true } rustc-hash = { version = "2.1", optional = true } -serde_json = { version = "1.0.149", optional = true } +serde_json = { version = "1.0", optional = true } uuid = { version = "1.23", features = ["v4", "serde"] } -supports-hyperlinks = "3.2.0" +supports-hyperlinks = "3.2" +memchr = "2.8" +smallvec = "1.15" +ownable = { version = "1.0", optional = true } +databake = { git = "https://github.com/suprohub/databake", optional = true, features = ["derive", "uuid"] } [dev-dependencies] chrono = "0.4" -serde_json = "1.0.149" +serde_json = "1.0" diff --git a/README.md b/README.md index 3e9c8b4..cb96e79 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ println!("{:p}", component); - [x] Terminal integration - [x] Serde integration - [x] SimdNbt integration -- [ ] MiniMessages integration +- [ ] MiniMessage integration - [ ] Extensibility integration ### Test diff --git a/examples/main.rs b/examples/main.rs index 1dcd2f7..8a1ac5f 100644 --- a/examples/main.rs +++ b/examples/main.rs @@ -12,7 +12,7 @@ use text_components::custom::{CustomContent, CustomData, CustomRegistry, Payload #[cfg(feature = "nbt")] use text_components::nbt::{NbtBuilder, ToSNBT}; use text_components::{ - Modifier, TextComponent, + Modifier, RawTextComponent, content::{NbtSource, ObjectPlayer, Resolvable}, fmt::set_display_resolutor, format::Color, @@ -23,7 +23,7 @@ use text_components::{ use uuid::Uuid; struct EmptyResolutor; -impl TextResolutor for EmptyResolutor { +impl<'a> TextResolutor<'a> for EmptyResolutor { fn translate(&self, key: &str) -> Option { match key { "content" => Some(String::from( @@ -38,10 +38,10 @@ impl TextResolutor for EmptyResolutor { _ => None, } } - fn resolve_content(&self, resolvable: &Resolvable) -> TextComponent { + fn resolve_content(&self, resolvable: &Resolvable) -> RawTextComponent<'a> { match resolvable { - Resolvable::Scoreboard { .. } => TextComponent::plain("5"), - Resolvable::Entity { .. } => TextComponent::plain("MrMelther") + Resolvable::Scoreboard { .. } => RawTextComponent::plain("5"), + Resolvable::Entity { .. } => RawTextComponent::plain("MrMelther") .insertion("MrMelther") .click_event(ClickEvent::suggest_command("/msg MrMelther ")) .hover_event(HoverEvent::show_entity( @@ -50,7 +50,7 @@ impl TextResolutor for EmptyResolutor { Some("MrMelther"), )), #[cfg(feature = "nbt")] - Resolvable::NBT { .. } => TextComponent::plain( + Resolvable::NBT { .. } => RawTextComponent::plain( Nbt::Some(BaseNbt::new( "", NbtCompound::from_values(vec![ @@ -65,12 +65,12 @@ impl TextResolutor for EmptyResolutor { ), #[cfg(not(feature = "nbt"))] Resolvable::NBT { .. } => { - TextComponent::plain("{base:3.0d,id:\"minecraft:entity_interaction_range\"}") + RawTextComponent::plain("{base:3.0d,id:\"minecraft:entity_interaction_range\"}") } } } #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData) -> Option { + fn resolve_custom(&self, data: &CustomData) -> Option> { if data.id == "time" { return Some(TimeContent.resolve((), Payload::Empty)); } @@ -78,14 +78,14 @@ impl TextResolutor for EmptyResolutor { } } #[cfg(feature = "custom")] -impl CustomRegistry for EmptyResolutor { +impl<'a> CustomRegistry<'a> for EmptyResolutor { type Data = (); - fn register_content(&mut self, _id: &'static str, _content: T) { + fn register_content>(&mut self, _id: &'a str, _content: T) { todo!() } - fn get_content(&self, _id: String) -> Box> { + fn get_content(&self, _id: String) -> Box> { Box::new(TimeContent) } } @@ -96,35 +96,32 @@ const RESOLUBLE: Translation<4> = Translation("resoluble"); #[cfg(feature = "custom")] struct TimeContent; #[cfg(feature = "custom")] -impl CustomContent for TimeContent { +impl<'a> CustomContent<'a> for TimeContent { type Reg = EmptyResolutor; - fn as_data(&self) -> CustomData { + fn as_data(&self) -> CustomData<'a> { CustomData { id: std::borrow::Cow::Borrowed("time"), payload: Payload::Empty, } } - fn resolve(&self, _data: (), _payload: Payload) -> TextComponent { - TextComponent::plain(Utc::now().format("%H:%M").to_string()) + fn resolve(&self, _data: (), _payload: Payload) -> RawTextComponent<'a> { + RawTextComponent::plain(Utc::now().format("%H:%M").to_string()) } } fn main() { set_display_resolutor(&EmptyResolutor); - let mut resolubles = RESOLUBLE + let resolubles = RESOLUBLE .message([ ObjectPlayer::name("MrMelther").reset(), - TextComponent::scoreboard("MrMelther", "objective").reset(), - TextComponent::entity("@p", None).reset(), - TextComponent::nbt("attributes[2]", NbtSource::entity("@p"), false, None).reset(), + RawTextComponent::scoreboard("MrMelther", "objective").reset(), + RawTextComponent::entity("@p", None).reset(), + RawTextComponent::nbt("attributes[2]", NbtSource::entity("@p"), false, None).reset(), ]) .color_hex("#6f00ff"); - #[cfg(feature = "custom")] - (&mut resolubles).add_children(vec!["\n Custom: ".into(), TimeContent.reset()]); - let component = CONTENT .message([ "This text is Blue!".reset().color(Color::Blue), @@ -144,8 +141,16 @@ fn main() { .reset(), ]) .color(Color::Green) - .bold(true) - .add_child(resolubles); + .bold(true); + + let component = cfg_select! { + feature = "custom" => { + component.add_child(resolubles.add_children(vec!["\n Custom: ".into(), TimeContent.reset()])) + } + _ => { + component + } + }; println!("\nDebug:\n{:?}", component); #[cfg(feature = "serde")] diff --git a/examples/nbt.rs b/examples/nbt.rs index 5fe74e9..24d4077 100644 --- a/examples/nbt.rs +++ b/examples/nbt.rs @@ -1,6 +1,6 @@ use simdnbt::owned::{BaseNbt, Nbt, NbtCompound, NbtTag}; use text_components::{ - Modifier, TextComponent, + Modifier, RawTextComponent, format::Color, interactivity::{ClickEvent, HoverEvent}, nbt::{NbtBuilder, ToSNBT}, @@ -19,7 +19,7 @@ fn main() -> Result<(), String> { ("string".into(), NbtTag::String("This is a text".into())), ]), )); - let component = TextComponent::nbt_display(nbt); + let component = RawTextComponent::nbt_display(nbt); println!( "tellraw @p {}", component.build(&NoResolutor, NbtBuilder).to_snbt() @@ -38,8 +38,8 @@ fn main() -> Result<(), String> { ]) .build(&NoResolutor, NbtBuilder); println!("{:?}", nbt); - let component = - TextComponent::from_nbt(&nbt).ok_or(String::from("Cannot recompose the TextComponent!"))?; + let component = RawTextComponent::from_nbt(&nbt) + .ok_or(String::from("Cannot recompose the TextComponent!"))?; println!("{:?}", component); println!("{:p}", component); Ok(()) diff --git a/examples/serde.rs b/examples/serde.rs index b9f9e83..4c8427d 100644 --- a/examples/serde.rs +++ b/examples/serde.rs @@ -1,11 +1,11 @@ -use text_components::{Modifier, TextComponent, format::Color, translation::TranslatedMessage}; +use text_components::{Modifier, RawTextComponent, format::Color, translation::TranslatedMessage}; fn main() { - let component: TextComponent = TranslatedMessage::new("key", None) + let component: RawTextComponent = TranslatedMessage::new("key", None) .color(Color::Blue) .bold(true); println!("{}", serde_json::to_string_pretty(&component).unwrap()); - let component: TextComponent = serde_json::from_str( + let component: RawTextComponent = serde_json::from_str( "{ \"text\": \"This is a Serde test\", \"color\": \"blue\", diff --git a/examples/snbt.rs b/examples/snbt.rs index f330818..b021e6c 100644 --- a/examples/snbt.rs +++ b/examples/snbt.rs @@ -1,4 +1,4 @@ -use text_components::TextComponent; +use text_components::RawTextComponent; fn main() { use std::io::{Write, stdin, stdout}; @@ -14,7 +14,7 @@ fn main() { if let Some('\r') = s.chars().next_back() { s.pop(); } - let component = TextComponent::from_snbt(&s); + let component = RawTextComponent::from_snbt(&s); match component { Ok(component) => { println!("{:?}", component); diff --git a/src/content.rs b/src/content.rs index 3c569cc..d73204a 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,30 +1,33 @@ #[cfg(feature = "custom")] use crate::custom::CustomData; use crate::{ - TextComponent, format::Format, interactivity::Interactivity, translation::TranslatedMessage, + RawTextComponent, format::Format, interactivity::Interactivity, translation::TranslatedMessage, }; use std::borrow::Cow; #[derive(Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case", untagged))] -pub enum Content { +pub enum Content<'a> { Text { - text: Cow<'static, str>, + text: Cow<'a, str>, }, - Translate(TranslatedMessage), + Translate(TranslatedMessage<'a>), Keybind { - keybind: Cow<'static, str>, + keybind: Cow<'a, str>, }, /// #### Needs [resolution](TextComponent::resolve) #[cfg(feature = "custom")] - Custom(CustomData), - Object(Object), + Custom(CustomData<'a>), + Object(Object<'a>), /// #### Needs [resolution](TextComponent::resolve) - Resolvable(Resolvable), + Resolvable(Resolvable<'a>), } -impl From for Content { +impl<'a> From for Content<'a> { fn from(value: String) -> Self { Content::Text { text: Cow::Owned(value), @@ -33,18 +36,21 @@ impl From for Content { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub enum Object { +pub enum Object<'a> { Atlas { #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - atlas: Option>, - sprite: Cow<'static, str>, + atlas: Option>, + sprite: Cow<'a, str>, }, Player { - player: ObjectPlayer, + player: ObjectPlayer<'a>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Clone::clone", default) @@ -53,13 +59,16 @@ pub enum Object { }, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct ObjectPlayer { +pub struct ObjectPlayer<'a> { #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub name: Option>, + pub name: Option>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) @@ -69,16 +78,16 @@ pub struct ObjectPlayer { feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub texture: Option>, + pub texture: Option>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Vec::is_empty", default) )] - pub properties: Vec, + pub properties: Vec>, } -impl ObjectPlayer { +impl<'a> ObjectPlayer<'a> { /// Creates a [ObjectPlayer] from a player's name. - pub fn name>>(name: T) -> Self { + pub fn name(name: impl Into>) -> Self { ObjectPlayer { name: Some(name.into()), id: None, @@ -96,7 +105,7 @@ impl ObjectPlayer { } } /// Creates a [ObjectPlayer] from the path to a texture of a resource pack. - pub fn texture>>(path: T) -> Self { + pub fn texture(path: impl Into>) -> Self { ObjectPlayer { name: None, id: None, @@ -107,9 +116,9 @@ impl ObjectPlayer { /// Creates a [ObjectPlayer] from a player's skin properties. /// * `value` - A [texture data json](https://minecraft.wiki/w/Mojang_API#Query_player's_skin_and_cape) encoded in Base64 /// * `signature` - An optional Mojang's signature, also encoded in Base64 - pub fn property>, R: Into>>( - value: T, - signature: Option, + pub fn property( + value: impl Into>, + signature: Option>>, ) -> Self { ObjectPlayer { name: None, @@ -131,36 +140,42 @@ impl ObjectPlayer { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct PlayerProperties { - pub name: Cow<'static, str>, - pub value: Cow<'static, str>, - pub signature: Option>, +pub struct PlayerProperties<'a> { + pub name: Cow<'a, str>, + pub value: Cow<'a, str>, + pub signature: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub enum Resolvable { +pub enum Resolvable<'a> { /// The selector must only accept 1 target /// #### Needs [resolution](TextComponent::resolve) #[cfg_attr(feature = "serde", serde(rename = "score"))] Scoreboard { #[cfg_attr(feature = "serde", serde(rename = "name"))] - selector: Cow<'static, str>, - objective: Cow<'static, str>, + selector: Cow<'a, str>, + objective: Cow<'a, str>, }, /// #### Needs [resolution](TextComponent::resolve) #[cfg_attr(feature = "serde", serde(untagged))] Entity { - selector: Cow<'static, str>, + selector: Cow<'a, str>, #[cfg_attr(feature = "serde", serde(default = "Resolvable::entity_separator"))] - separator: Box, + separator: Box>, }, /// #### Needs [resolution](TextComponent::resolve) #[cfg_attr(feature = "serde", serde(untagged))] NBT { #[cfg_attr(feature = "serde", serde(rename = "nbt"))] - path: Cow<'static, str>, + path: Cow<'a, str>, // This meants to represent that this component should be // replaced with the one inside the nbt selected if possible #[cfg_attr( @@ -169,14 +184,14 @@ pub enum Resolvable { )] interpret: Option, #[cfg_attr(feature = "serde", serde(default = "Resolvable::nbt_separator"))] - separator: Box, + separator: Box>, #[cfg_attr(feature = "serde", serde(flatten, default = "NbtSource::Entity"))] - source: NbtSource, + source: NbtSource<'a>, }, } -impl Resolvable { - pub fn entity_separator() -> Box { - Box::new(TextComponent { +impl<'a> Resolvable<'a> { + pub fn entity_separator() -> Box> { + Box::new(RawTextComponent { content: Content::Text { text: Cow::Borrowed(", "), }, @@ -187,8 +202,8 @@ impl Resolvable { ..Default::default() }) } - pub fn nbt_separator() -> Box { - Box::new(TextComponent { + pub fn nbt_separator() -> Box> { + Box::new(RawTextComponent { content: Content::Text { text: Cow::Borrowed(", "), }, @@ -198,16 +213,19 @@ impl Resolvable { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::content))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -pub enum NbtSource { - Entity(Cow<'static, str>), - Block(Cow<'static, str>), - Storage(Cow<'static, str>), +pub enum NbtSource<'a> { + Entity(Cow<'a, str>), + Block(Cow<'a, str>), + Storage(Cow<'a, str>), } -impl NbtSource { +impl<'a> NbtSource<'a> { /// Creates a [NbtSource] from a entity selector. - pub fn entity>>(selector: T) -> Self { + pub fn entity(selector: impl Into>) -> Self { NbtSource::Entity(selector.into()) } /// Creates a [NbtSource] from a block coordinates. @@ -215,14 +233,14 @@ impl NbtSource { NbtSource::Block(Cow::Owned(format!("{x} {y} {z}"))) } /// Creates a [NbtSource] from a Nbt Storage identifier. - pub fn storage>>(identifier: T) -> Self { + pub fn storage(identifier: impl Into>) -> Self { NbtSource::Storage(identifier.into()) } } -impl From for TextComponent { - fn from(value: Content) -> Self { - TextComponent { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: Content<'a>) -> Self { + RawTextComponent { content: value, children: Vec::new(), format: Format::new(), @@ -230,9 +248,9 @@ impl From for TextComponent { } } } -impl From for TextComponent { - fn from(value: Object) -> Self { - TextComponent { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: Object<'a>) -> Self { + RawTextComponent { content: Content::Object(value), children: Vec::new(), format: Format::new(), @@ -240,9 +258,9 @@ impl From for TextComponent { } } } -impl From for TextComponent { - fn from(value: ObjectPlayer) -> Self { - TextComponent { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: ObjectPlayer<'a>) -> Self { + RawTextComponent { content: Content::Object(Object::Player { player: value, hat: true, @@ -253,9 +271,9 @@ impl From for TextComponent { } } } -impl From for TextComponent { - fn from(value: Resolvable) -> Self { - TextComponent { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: Resolvable<'a>) -> Self { + RawTextComponent { content: Content::Resolvable(value), children: Vec::new(), format: Format::new(), diff --git a/src/custom.rs b/src/custom.rs index 473cc26..5fd448c 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -1,10 +1,13 @@ -use crate::TextComponent; +use crate::RawTextComponent; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::custom))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct CustomData { - pub id: Cow<'static, str>, +pub struct CustomData<'a> { + pub id: Cow<'a, str>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Payload::is_empty", default) @@ -13,6 +16,9 @@ pub struct CustomData { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::custom))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] pub enum Payload { #[default] @@ -25,29 +31,32 @@ impl Payload { } } -pub trait CustomRegistry { +pub trait CustomRegistry<'a> { type Data; - fn register_content(&mut self, id: &'static str, content: T); - fn get_content(&self, id: String) -> Box>; + fn register_content>(&mut self, id: &'a str, content: T); + fn get_content(&self, id: String) -> Box>; } -pub trait CustomContent { - type Reg: CustomRegistry; - fn as_data(&self) -> CustomData; - fn resolve(&self, data: ::Data, payload: Payload) - -> TextComponent; +pub trait CustomContent<'a> { + type Reg: CustomRegistry<'a>; + fn as_data(&self) -> CustomData<'a>; + fn resolve( + &self, + data: >::Data, + payload: Payload, + ) -> RawTextComponent<'a>; } -impl From for TextComponent { - fn from(value: CustomData) -> Self { - TextComponent { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: CustomData<'a>) -> Self { + RawTextComponent { content: crate::content::Content::Custom(value), ..Default::default() } } } -impl From for TextComponent { +impl<'a, T: CustomContent<'a> + 'a> From for RawTextComponent<'a> { fn from(value: T) -> Self { - TextComponent::custom(value) + RawTextComponent::custom(value) } } diff --git a/src/fmt.rs b/src/fmt.rs index 32ca573..a382a07 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -1,5 +1,5 @@ use crate::{ - TextComponent, + RawTextComponent, content::{Content, Object}, format::{Color, Format}, interactivity::{ClickEvent, Interactivity}, @@ -62,10 +62,10 @@ const OBFUSCATION_CHARS: [char; 822] = [ pub struct TextBuilder; impl TextBuilder { - fn stringify_content( + fn stringify_content<'a, R: TextResolutor<'a> + ?Sized, S: BuildTarget<'a>>( target: &S, resolutor: &R, - component: &TextComponent, + component: &RawTextComponent<'a>, ) -> S::Result where S::Result: From + ToString + Display, @@ -83,10 +83,10 @@ impl TextBuilder { let parts = resolutor.split_translation(translated); let mut built_parts = vec![]; for (part, pos) in parts { - let component_part = TextComponent { + let component_part = RawTextComponent { content: part.into(), format: component.format.clone(), - ..TextComponent::new() + ..RawTextComponent::new() }; built_parts.push( target @@ -98,7 +98,7 @@ impl TextBuilder { && pos <= args.len() && let Some(arg) = args.get(pos - 1) { - let arg_part = TextComponent { + let arg_part = RawTextComponent { content: arg.content.clone(), children: arg.children.clone(), format: arg.format.mix(&component.format), @@ -126,12 +126,12 @@ impl TextBuilder { } } } -impl BuildTarget for TextBuilder { +impl<'a> BuildTarget<'a> for TextBuilder { type Result = String; - fn build_component( + fn build_component + ?Sized>( &self, resolutor: &R, - component: &TextComponent, + component: &RawTextComponent<'a>, ) -> String { Self::stringify_content(self, resolutor, component) + &component @@ -144,12 +144,12 @@ impl BuildTarget for TextBuilder { } pub struct PrettyTextBuilder; -impl BuildTarget for PrettyTextBuilder { +impl<'a> BuildTarget<'a> for PrettyTextBuilder { type Result = ColoredString; - fn build_component( + fn build_component + ?Sized>( &self, resolutor: &R, - component: &TextComponent, + component: &RawTextComponent<'a>, ) -> ColoredString { let mut final_text = TextBuilder::stringify_content(self, resolutor, component); @@ -161,7 +161,7 @@ impl BuildTarget for PrettyTextBuilder { .children .iter() .map(|child| { - let child = TextComponent { + let child = RawTextComponent { content: child.content.clone(), children: child.children.clone(), format: child.format.mix(&component.format), @@ -222,7 +222,7 @@ impl BuildTarget for PrettyTextBuilder { .children .iter() .map(|child| { - let child = TextComponent { + let child = RawTextComponent { content: child.content.clone(), children: child.children.clone(), format: child.format.mix(&component.format), @@ -237,41 +237,41 @@ impl BuildTarget for PrettyTextBuilder { } } -impl TextComponent { - pub fn to_plain(&self, resolutor: &R) -> String { +impl<'a> RawTextComponent<'a> { + pub fn to_plain + ?Sized>(&self, resolutor: &R) -> String { self.build(resolutor, TextBuilder) } - pub fn to_pretty(&self, resolutor: &R) -> ColoredString { + pub fn to_pretty + ?Sized>(&self, resolutor: &R) -> ColoredString { self.build(resolutor, PrettyTextBuilder) } } -static mut DISPLAY_RESOLUTOR: &dyn TextResolutor = &NoResolutor; +static mut DISPLAY_RESOLUTOR: &'static dyn for<'a> TextResolutor<'a> = + &NoResolutor as &'static dyn for<'a> TextResolutor<'a>; static mut INITIALIZED: bool = false; -pub fn set_display_resolutor(resolutor: &'static T) { +pub fn set_display_resolutor TextResolutor<'a>>(resolutor: &'static T) { unsafe { if !INITIALIZED { - DISPLAY_RESOLUTOR = resolutor; + DISPLAY_RESOLUTOR = resolutor as &'static dyn for<'a> TextResolutor<'a>; INITIALIZED = true; } } } -impl Display for TextComponent { +impl Display for RawTextComponent<'_> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}", unsafe { self.to_plain(DISPLAY_RESOLUTOR) }) } } -/// Clearly a Pointer, not 'p' because of pretty, OF COURSE -impl Pointer for TextComponent { +impl<'a> Pointer for RawTextComponent<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { write!(f, "{}", unsafe { self.to_pretty(DISPLAY_RESOLUTOR) }) } } -impl Debug for TextComponent { +impl<'a> Debug for RawTextComponent<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let mut debug = f.debug_struct("TextComponent"); debug.field("content", &self.content); @@ -288,7 +288,7 @@ impl Debug for TextComponent { } } -impl Debug for Content { +impl<'a> Debug for Content<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match self { Self::Text { text } => Debug::fmt(&text, f), @@ -302,7 +302,7 @@ impl Debug for Content { } } -impl Debug for Format { +impl<'a> Debug for Format<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { if let Some(Color::White) = self.color && let Some(Cow::Borrowed("minecraft:default")) = self.font @@ -372,7 +372,7 @@ impl Debug for Format { } } -impl Debug for Interactivity { +impl<'a> Debug for Interactivity<'a> { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { let mut debug = f.debug_map(); if self.insertion.is_some() { diff --git a/src/format.rs b/src/format.rs index cc89888..c2d77cf 100644 --- a/src/format.rs +++ b/src/format.rs @@ -2,8 +2,11 @@ use colored::{ColoredString, Colorize}; use std::{borrow::Cow, fmt::Display}; #[derive(Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::format))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct Format { +pub struct Format<'a> { #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) @@ -13,7 +16,7 @@ pub struct Format { feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub font: Option>, + pub font: Option>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) @@ -46,12 +49,12 @@ pub struct Format { pub shadow_color: Option, } -impl Default for Format { +impl<'a> Default for Format<'a> { fn default() -> Self { Self::new() } } -impl Format { +impl<'a> Format<'a> { pub const fn new() -> Self { Self { color: None, @@ -84,7 +87,7 @@ impl Format { } self } - pub fn font>>(mut self, font: F) -> Self { + pub fn font(mut self, font: impl Into>) -> Self { self.font = Some(font.into()); self } @@ -126,12 +129,12 @@ impl Format { self.shadow_color = None; self } - pub fn mix(&self, other: &Format) -> Format { + pub fn mix(&self, other: &Format<'a>) -> Format<'a> { Format { color: if self.color.is_some() { - self.color.clone() + self.color } else { - other.color.clone() + other.color }, font: if self.font.is_some() { self.font.clone() @@ -172,7 +175,10 @@ impl Format { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::format))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum Color { @@ -211,28 +217,31 @@ impl Color { } None } - pub fn colorize_text>(&self, text: T) -> ColoredString { + + pub fn colorize_text(&self, text: impl Into) -> ColoredString { + let text = text.into(); match self { - Color::Black => text.into().black(), - Color::DarkBlue => text.into().blue(), - Color::DarkGreen => text.into().green(), - Color::DarkAqua => text.into().cyan(), - Color::DarkRed => text.into().red(), - Color::DarkPurple => text.into().magenta(), - Color::Gold => text.into().yellow(), - Color::Gray => text.into().white(), - Color::DarkGray => text.into().bright_black(), - Color::Blue => text.into().bright_blue(), - Color::Green => text.into().bright_green(), - Color::Aqua => text.into().bright_cyan(), - Color::Red => text.into().bright_red(), - Color::LightPurple => text.into().bright_magenta(), - Color::Yellow => text.into().bright_yellow(), - Color::White => text.into().bright_white(), - Color::Rgb(r, g, b) => text.into().truecolor(*r, *g, *b), + Color::Black => text.black(), + Color::DarkBlue => text.blue(), + Color::DarkGreen => text.green(), + Color::DarkAqua => text.cyan(), + Color::DarkRed => text.red(), + Color::DarkPurple => text.magenta(), + Color::Gold => text.yellow(), + Color::Gray => text.white(), + Color::DarkGray => text.bright_black(), + Color::Blue => text.bright_blue(), + Color::Green => text.bright_green(), + Color::Aqua => text.bright_cyan(), + Color::Red => text.bright_red(), + Color::LightPurple => text.bright_magenta(), + Color::Yellow => text.bright_yellow(), + Color::White => text.bright_white(), + Color::Rgb(r, g, b) => text.truecolor(*r, *g, *b), } } } + impl Display for Color { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/src/interactivity.rs b/src/interactivity.rs index 7fd7c1b..e5b3866 100644 --- a/src/interactivity.rs +++ b/src/interactivity.rs @@ -1,18 +1,26 @@ use uuid::Uuid; -use crate::TextComponent; +use crate::RawTextComponent; #[cfg(feature = "custom")] use crate::custom::CustomData; use std::borrow::Cow; +/// Represents interactive elements of a text component, such as click and hover events, +/// and text insertion on shift-click. #[derive(Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::interactivity))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct Interactivity { +pub struct Interactivity<'a> { + /// The text to insert into the chat when the player shift-clicks this component. #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub insertion: Option>, + pub insertion: Option>, + + /// The action performed when the player clicks on this component. #[cfg_attr( feature = "serde", serde( @@ -21,7 +29,9 @@ pub struct Interactivity { default ) )] - pub click: Option, + pub click: Option>, + + /// The action performed when the player hovers over this component. #[cfg_attr( feature = "serde", serde( @@ -30,16 +40,16 @@ pub struct Interactivity { default ) )] - pub hover: Option, + pub hover: Option>, } -impl Default for Interactivity { +impl<'a> Default for Interactivity<'a> { fn default() -> Self { Self::new() } } -impl Interactivity { +impl<'a> Interactivity<'a> { pub const fn new() -> Self { Self { insertion: None, @@ -47,9 +57,11 @@ impl Interactivity { hover: None, } } + pub fn is_none(&self) -> bool { self.insertion.is_none() && self.click.is_none() && self.hover.is_none() } + pub fn mix(&self, other: &mut Self) { if self.insertion.is_some() { other.insertion = self.insertion.clone() @@ -63,112 +75,152 @@ impl Interactivity { } } +/// Defines an action that occurs when a text component is clicked. #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::interactivity))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "action", rename_all = "snake_case"))] -pub enum ClickEvent { +pub enum ClickEvent<'a> { + /// Opens the given URL. OpenUrl { - url: Cow<'static, str>, + /// The URL to open. + url: Cow<'a, str>, }, + /// Runs a command as the player. RunCommand { - command: Cow<'static, str>, + /// The command to execute. + command: Cow<'a, str>, }, + /// Replaces the player's chat input with a command (without sending it). SuggestCommand { - command: Cow<'static, str>, + /// The command to suggest. + command: Cow<'a, str>, }, + /// Changes the current page in a book. ChangePage { + /// The page number to switch to (1-based, signed for protocol compatibility). page: i32, }, + /// Copies the given value to the clipboard. CopyToClipboard { - value: Cow<'static, str>, + /// The string to copy. + value: Cow<'a, str>, }, + /// Shows a custom dialog. ShowDialog { - dialog: Cow<'static, str>, + /// Either a dialog resource identifier or a full dialog definition. + dialog: Cow<'a, str>, }, + /// A custom click event, available only with the `custom` feature. #[cfg(feature = "custom")] - Custom(CustomData), + Custom(CustomData<'a>), } -impl ClickEvent { + +impl<'a> ClickEvent<'a> { /// Creates a [ClickEvent] that opens a url when triggered. - pub fn open_url>>(url: T) -> Self { + pub fn open_url(url: impl Into>) -> Self { ClickEvent::OpenUrl { url: url.into() } } + /// Creates a [ClickEvent] that runs a command when triggered. - pub fn run_command>>(command: T) -> Self { + pub fn run_command(command: impl Into>) -> Self { ClickEvent::RunCommand { command: command.into(), } } + /// Creates a [ClickEvent] that replaces the chat input with a command when triggered. - pub fn suggest_command>>(command: T) -> Self { + pub fn suggest_command(command: impl Into>) -> Self { ClickEvent::SuggestCommand { command: command.into(), } } + /// Creates a [ClickEvent] that changes the page of a book when triggered. pub fn change_page(page: u32) -> Self { ClickEvent::ChangePage { page: page as i32 } } + /// Creates a [ClickEvent] that copies it's content to the clipboard when triggered. - pub fn copy_to_clipboard>>(value: T) -> Self { + pub fn copy_to_clipboard(value: impl Into>) -> Self { ClickEvent::CopyToClipboard { value: value.into(), } } + /// Creates a [ClickEvent] that shows a custom dialog when triggered. /// * `dialog` - Either a dialog id or a dialog definition - pub fn show_dialog>>(dialog: T) -> Self { + pub fn show_dialog(dialog: impl Into>) -> Self { ClickEvent::ShowDialog { dialog: dialog.into(), } } } +/// Defines an action that occurs when hovering over a text component. #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::interactivity))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "action", rename_all = "snake_case"))] -pub enum HoverEvent { +pub enum HoverEvent<'a> { + /// Displays a text component as a tooltip. ShowText { - value: Box, + /// The text component to display. + value: Box>, }, + /// Displays an item tooltip. ShowItem { - id: Cow<'static, str>, + /// The item identifier (e.g., `minecraft:stone`). + id: Cow<'a, str>, + /// Optional stack size to display. #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] count: Option, + /// Optional stringified item components (JSON). #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - components: Option>, + components: Option>, }, + /// Displays an entity tooltip. ShowEntity { + /// An optional custom name component to show instead of the entity's actual name. #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - name: Option>, - id: Cow<'static, str>, + name: Option>>, + /// The entity type identifier (e.g., `minecraft:creeper`). + id: Cow<'a, str>, + /// The UUID of the entity. + #[cfg_attr(feature = "ownable", ownable(clone))] uuid: Uuid, }, } -impl HoverEvent { + +impl<'a> HoverEvent<'a> { /// Creates a [HoverEvent] that will show a text component. - pub fn show_text>(text: T) -> Self { + pub fn show_text(text: impl Into>) -> Self { HoverEvent::ShowText { value: Box::new(text.into()), } } + /// Creates a [HoverEvent] that will show an item. /// * `id` - The id of the item /// * `count` - If [Some] shows the amount of items /// * `components` - An optional stringified version of the item's components - pub fn show_item>, R: Into>>( - id: T, + pub fn show_item( + id: impl Into>, count: Option, - components: Option, + components: Option>>, ) -> Self { HoverEvent::ShowItem { id: id.into(), @@ -176,14 +228,15 @@ impl HoverEvent { components: components.map(Into::into), } } + /// Creates a [HoverEvent] that will show an entity. /// * `id` - The id of the entity's type /// * `uuid` - The id of the targeted entity /// * `name` - If [Some] the name to display - pub fn show_entity>, R: Into>( - id: T, + pub fn show_entity( + id: impl Into>, uuid: Uuid, - name: Option, + name: Option>>, ) -> Self { HoverEvent::ShowEntity { name: name.map(|r| Box::new(r.into())), diff --git a/src/lib.rs b/src/lib.rs index 1ea0efc..e7978d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,8 @@ use crate::{ }; use std::borrow::Cow; +extern crate self as text_components; + #[cfg(feature = "build")] pub mod build; pub mod content; @@ -84,26 +86,31 @@ pub mod translation; /// component.to_pretty(resolutor); /// ``` #[derive(Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct TextComponent { +pub struct RawTextComponent<'a> { #[cfg_attr(feature = "serde", serde(flatten))] - pub content: Content, + pub content: Content<'a>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Vec::is_empty", rename = "extra", default) )] - pub children: Vec, + pub children: Vec>, #[cfg_attr(feature = "serde", serde(flatten))] - pub format: Format, + pub format: Format<'a>, #[cfg_attr(feature = "serde", serde(flatten))] - pub interactions: Interactivity, + pub interactions: Interactivity<'a>, } +pub type TextComponent = RawTextComponent<'static>; + // Constructors -impl TextComponent { +impl<'a> RawTextComponent<'a> { /// Creates an empty [TextComponent], useful to make it the parent. pub const fn new() -> Self { - TextComponent { + RawTextComponent { content: Content::Text { text: Cow::Borrowed(""), }, @@ -119,8 +126,8 @@ impl TextComponent { /// // Results in "Test Component" /// TextComponent::const_plain("Test Component"); /// ``` - pub const fn const_plain(text: &'static str) -> Self { - TextComponent { + pub const fn const_plain(text: &'a str) -> Self { + RawTextComponent { content: Content::Text { text: Cow::Borrowed(text), }, @@ -140,8 +147,8 @@ impl TextComponent { /// ``` /// let component: TextComponent = "Test Component".into(); /// ``` - pub fn plain>>(text: T) -> Self { - TextComponent { + pub fn plain(text: impl Into>) -> Self { + RawTextComponent { content: Content::Text { text: text.into() }, children: vec![], format: Format::new(), @@ -170,8 +177,8 @@ impl TextComponent { /// // Results in "The Rust compiler was killed by you using magic". /// TextComponent::translated(DEATH_ATTACK_INDIRECT_MAGIC.message(["The Rust compiler", "you"])); /// ``` - pub const fn translated(message: TranslatedMessage) -> Self { - TextComponent { + pub const fn translated(message: TranslatedMessage<'a>) -> Self { + RawTextComponent { content: Content::Translate(message), children: vec![], format: Format::new(), @@ -187,11 +194,8 @@ impl TextComponent { /// // Displays the Diamond Sword sprite /// TextComponent::atlas("item/diamond_sword", Some("minecraft:items")); /// ``` - pub fn atlas>, R: Into>>( - sprite: T, - atlas: Option, - ) -> Self { - TextComponent { + pub fn atlas(sprite: impl Into>, atlas: Option>>) -> Self { + RawTextComponent { content: Content::Object(Object::Atlas { atlas: atlas.map(Into::into), sprite: sprite.into(), @@ -209,8 +213,8 @@ impl TextComponent { /// // Displays the head of Jeb_ /// TextComponent::player_head(ObjectPlayer::name("Jeb_"), true); /// ``` - pub const fn player_head(player: ObjectPlayer, hat: bool) -> Self { - TextComponent { + pub const fn player_head(player: ObjectPlayer<'a>, hat: bool) -> Self { + RawTextComponent { content: Content::Object(Object::Player { player, hat }), children: Vec::new(), format: Format::new(), @@ -228,11 +232,11 @@ impl TextComponent { /// TextComponent::scoreboard("@p", "deaths"); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn scoreboard>, R: Into>>( - selector: T, - objective: R, + pub fn scoreboard( + selector: impl Into>, + objective: impl Into>, ) -> Self { - TextComponent { + RawTextComponent { content: Content::Resolvable(Resolvable::Scoreboard { selector: selector.into(), objective: objective.into(), @@ -252,8 +256,8 @@ impl TextComponent { /// TextComponent::entity("@a", Some(" ".into())); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn entity>>(selector: T, separator: Option) -> Self { - TextComponent { + pub fn entity(selector: impl Into>, separator: Option) -> Self { + RawTextComponent { content: Content::Resolvable(Resolvable::Entity { selector: selector.into(), separator: match separator { @@ -278,13 +282,13 @@ impl TextComponent { /// TextComponent::nbt("Health", NbtSource::entity("@p"), false, None); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn nbt>>( - path: T, - source: NbtSource, + pub fn nbt( + path: impl Into>, + source: NbtSource<'a>, interpret: bool, separator: Option, ) -> Self { - TextComponent { + RawTextComponent { content: Content::Resolvable(Resolvable::NBT { path: path.into(), interpret: if interpret { Some(true) } else { None }, @@ -301,8 +305,8 @@ impl TextComponent { } #[cfg(feature = "custom")] - pub fn custom(content: T) -> TextComponent { - TextComponent { + pub fn custom(content: impl CustomContent<'a> + 'a) -> RawTextComponent<'a> { + RawTextComponent { content: Content::Custom(content.as_data()), children: vec![], format: Format::new(), @@ -311,43 +315,43 @@ impl TextComponent { } } -impl Default for TextComponent { +impl<'a> Default for RawTextComponent<'a> { fn default() -> Self { - TextComponent::new() + RawTextComponent::new() } } -impl From<&'static str> for TextComponent { - fn from(value: &'static str) -> Self { - TextComponent::const_plain(value) +impl<'a> From<&'a str> for RawTextComponent<'a> { + fn from(value: &'a str) -> Self { + RawTextComponent::const_plain(value) } } -impl From for TextComponent { +impl<'a> From for RawTextComponent<'a> { fn from(value: String) -> Self { - TextComponent::plain(value) + RawTextComponent::plain(value) } } -pub trait Modifier { +pub trait Modifier<'a> { type Output; /// Adds a child at the end of a text component - fn add_child>(self, child: T) -> Self::Output; + fn add_child>>(self, child: T) -> Self::Output; /// Appends a [vec] of [Into]<[TextComponent]> as children of this component - fn add_children>(self, children: Vec) -> Self::Output; + fn add_children>>(self, children: Vec) -> Self::Output; /// Sets the Shift+Click chat insertion string - fn insertion>>(self, insertion: T) -> Self::Output; + fn insertion>>(self, insertion: T) -> Self::Output; /// Sets the [ClickEvent] for this component - fn click_event(self, click: ClickEvent) -> Self::Output; + fn click_event(self, click: ClickEvent<'a>) -> Self::Output; /// Sets the [HoverEvent] for this component - fn hover_event(self, hover: HoverEvent) -> Self::Output; + fn hover_event(self, hover: HoverEvent<'a>) -> Self::Output; /// Sets the [Color] of this component /// * If you want to use a hex code check [color_hex](TextComponent::color_hex) fn color(self, color: Color) -> Self::Output; /// Sets the color of this component from a 6 digit hex color /// * If you want to use a predefined color check [color](TextComponent::color) - fn color_hex(self, color: &str) -> Self::Output; + fn color_hex(self, color: &'a str) -> Self::Output; /// Sets the font used to display this component - fn font>>(self, font: F) -> Self::Output; + fn font>>(self, font: F) -> Self::Output; /// Makes this component **bold** fn bold(self, value: bool) -> Self::Output; /// Makes this component *italic* @@ -364,14 +368,14 @@ pub trait Modifier { fn reset(self) -> Self::Output; } -impl + Sized> Modifier for T { - type Output = TextComponent; - fn add_child>(self, child: F) -> TextComponent { +impl<'a, T: Into> + Sized> Modifier<'a> for T { + type Output = RawTextComponent<'a>; + fn add_child>>(self, child: F) -> RawTextComponent<'a> { let mut component = self.into(); component.children.push(child.into()); component } - fn add_children>(self, children: Vec) -> TextComponent { + fn add_children>>(self, children: Vec) -> RawTextComponent<'a> { let mut component = self.into(); for child in children { component.children.push(child.into()); @@ -379,151 +383,154 @@ impl + Sized> Modifier for T { component } - fn insertion>>(self, insertion: R) -> TextComponent { + fn insertion>>(self, insertion: R) -> RawTextComponent<'a> { let mut component = self.into(); component.interactions.insertion = Some(insertion.into()); component } - fn click_event(self, click: ClickEvent) -> TextComponent { + fn click_event(self, click: ClickEvent<'a>) -> RawTextComponent<'a> { let mut component = self.into(); component.interactions.click = Some(click); component } - fn hover_event(self, hover: HoverEvent) -> TextComponent { + fn hover_event(self, hover: HoverEvent<'a>) -> RawTextComponent<'a> { let mut component = self.into(); component.interactions.hover = Some(hover); component } - fn color(self, color: Color) -> TextComponent { + fn color(self, color: Color) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.color(color); component } - fn color_hex(self, color: &str) -> TextComponent { + fn color_hex(self, color: &'a str) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.color_hex(color); component } - fn font>>(self, font: F) -> TextComponent { + fn font>>(self, font: F) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.font(font); component } - fn bold(self, value: bool) -> TextComponent { + fn bold(self, value: bool) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.bold(value); component } - fn italic(self, value: bool) -> TextComponent { + fn italic(self, value: bool) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.italic(value); component } - fn underlined(self, value: bool) -> TextComponent { + fn underlined(self, value: bool) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.underlined(value); component } - fn strikethrough(self, value: bool) -> TextComponent { + fn strikethrough(self, value: bool) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.strikethrough(value); component } - fn obfuscated(self, value: bool) -> TextComponent { + fn obfuscated(self, value: bool) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.obfuscated(value); component } - fn shadow_color(self, a: u8, r: u8, g: u8, b: u8) -> TextComponent { + fn shadow_color(self, a: u8, r: u8, g: u8, b: u8) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.shadow_color(a, r, g, b); component } - fn reset(self) -> TextComponent { + fn reset(self) -> RawTextComponent<'a> { let mut component = self.into(); component.format = component.format.reset(); component } } -impl<'a> Modifier for &'a mut TextComponent { - type Output = &'a mut TextComponent; - fn add_child>(self, child: T) -> &'a mut TextComponent { +impl<'a> Modifier<'a> for &'a mut RawTextComponent<'a> { + type Output = &'a mut RawTextComponent<'a>; + fn add_child>>(self, child: T) -> &'a mut RawTextComponent<'a> { self.children.push(child.into()); self } - fn add_children>(self, children: Vec) -> &'a mut TextComponent { + fn add_children>>( + self, + children: Vec, + ) -> &'a mut RawTextComponent<'a> { for child in children { self.children.push(child.into()); } self } - fn insertion>>(self, insertion: T) -> &'a mut TextComponent { + fn insertion>>(self, insertion: T) -> &'a mut RawTextComponent<'a> { self.interactions.insertion = Some(insertion.into()); self } - fn click_event(self, click: ClickEvent) -> &'a mut TextComponent { + fn click_event(self, click: ClickEvent<'a>) -> &'a mut RawTextComponent<'a> { self.interactions.click = Some(click); self } - fn hover_event(self, hover: HoverEvent) -> &'a mut TextComponent { + fn hover_event(self, hover: HoverEvent<'a>) -> &'a mut RawTextComponent<'a> { self.interactions.hover = Some(hover); self } - fn color(self, color: Color) -> &'a mut TextComponent { + fn color(self, color: Color) -> &'a mut RawTextComponent<'a> { self.format.color = Some(color); self } - fn color_hex(self, color: &str) -> &'a mut TextComponent { + fn color_hex(self, color: &str) -> &'a mut RawTextComponent<'a> { if let Some(color) = Color::from_hex(color) { self.format.color = Some(color); } self } - fn font>>(self, font: F) -> &'a mut TextComponent { + fn font>>(self, font: F) -> &'a mut RawTextComponent<'a> { self.format.font = Some(font.into()); self } - fn bold(self, value: bool) -> &'a mut TextComponent { + fn bold(self, value: bool) -> &'a mut RawTextComponent<'a> { self.format.bold = Some(value); self } - fn italic(self, value: bool) -> &'a mut TextComponent { + fn italic(self, value: bool) -> &'a mut RawTextComponent<'a> { self.format.italic = Some(value); self } - fn underlined(self, value: bool) -> &'a mut TextComponent { + fn underlined(self, value: bool) -> &'a mut RawTextComponent<'a> { self.format.underlined = Some(value); self } - fn strikethrough(self, value: bool) -> &'a mut TextComponent { + fn strikethrough(self, value: bool) -> &'a mut RawTextComponent<'a> { self.format.strikethrough = Some(value); self } - fn obfuscated(self, value: bool) -> &'a mut TextComponent { + fn obfuscated(self, value: bool) -> &'a mut RawTextComponent<'a> { self.format.obfuscated = Some(value); self } - fn shadow_color(self, a: u8, r: u8, g: u8, b: u8) -> &'a mut TextComponent { + fn shadow_color(self, a: u8, r: u8, g: u8, b: u8) -> &'a mut RawTextComponent<'a> { self.format.shadow_color = Some(Format::parse_shadow_color(a, r, g, b)); self } - fn reset(self) -> &'a mut TextComponent { + fn reset(self) -> &'a mut RawTextComponent<'a> { self.format.color = Some(Color::White); self.format.font = Some(Cow::Borrowed("minecraft:default")); self.format.bold = Some(false); @@ -535,3 +542,182 @@ impl<'a> Modifier for &'a mut TextComponent { self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::resolving::{BuildTarget, NoResolutor, TextResolutor}; + use std::borrow::Cow; + + struct StringTarget; + impl<'a> BuildTarget<'a> for StringTarget { + type Result = String; + fn build_component + ?Sized>( + &self, + _resolutor: &R, + component: &RawTextComponent<'a>, + ) -> Self::Result { + fn extract_text(comp: &RawTextComponent) -> String { + let mut s = match &comp.content { + Content::Text { text } => text.to_string(), + Content::Translate(msg) => msg.key.to_string(), + _ => String::new(), + }; + for child in &comp.children { + s.push_str(&extract_text(child)); + } + s + } + extract_text(component) + } + } + + #[test] + fn test_new_empty_component() { + let component = TextComponent::new(); + assert_eq!( + component.content, + Content::Text { + text: Cow::Borrowed("") + } + ); + assert!(component.children.is_empty()); + } + + #[test] + fn test_plain_text() { + let component = TextComponent::plain("Hello"); + assert_eq!( + component.content, + Content::Text { + text: Cow::Borrowed("Hello") + } + ); + } + + #[test] + fn test_const_plain() { + let component = TextComponent::const_plain("World"); + assert_eq!( + component.content, + Content::Text { + text: Cow::Borrowed("World") + } + ); + } + + #[test] + fn test_player_head() { + let component = TextComponent::player_head(ObjectPlayer::name("Jeb_"), true); + assert!(matches!( + component.content, + Content::Object(Object::Player { .. }) + )); + } + + #[test] + fn test_scoreboard() { + let component = TextComponent::scoreboard("@p", "deaths"); + assert!(matches!( + component.content, + Content::Resolvable(Resolvable::Scoreboard { .. }) + )); + } + + #[test] + fn test_add_child_and_children() { + let parent = TextComponent::plain("Parent") + .add_child("Child1") + .add_children(vec!["Child2", "Child3"]); + assert_eq!(parent.children.len(), 3); + assert_eq!( + parent.children[0].content, + Content::Text { + text: Cow::Borrowed("Child1") + } + ); + } + + #[test] + fn test_insertion() { + let component = TextComponent::plain("text").insertion("insert me"); + assert_eq!( + component.interactions.insertion, + Some(Cow::Borrowed("insert me")) + ); + } + + #[test] + fn test_click_event() { + let event = ClickEvent::open_url("http://example.com"); + let component = TextComponent::plain("link").click_event(event.clone()); + assert_eq!(component.interactions.click, Some(event)); + } + + #[test] + fn test_hover_event() { + let hover = HoverEvent::show_text("tooltip"); + let component = TextComponent::plain("hover").hover_event(hover.clone()); + assert_eq!(component.interactions.hover, Some(hover)); + } + + #[test] + fn test_color_and_hex() { + let component = TextComponent::plain("colored").color_hex("#00ff00"); + assert!(component.format.color.is_some()); + } + + #[test] + fn test_formatting_bold_italic_underline() { + let component = TextComponent::plain("f") + .bold(true) + .italic(true) + .underlined(true) + .strikethrough(false); + assert_eq!(component.format.bold, Some(true)); + assert_eq!(component.format.italic, Some(true)); + assert_eq!(component.format.underlined, Some(true)); + assert_eq!(component.format.strikethrough, Some(false)); + } + + #[test] + fn test_reset_formatting() { + let component = TextComponent::plain("x") + .color(Color::Blue) + .bold(true) + .italic(true) + .reset(); + assert_eq!(component.format.color, Some(Color::White)); + assert_eq!(component.format.bold, Some(false)); + assert_eq!(component.format.italic, Some(false)); + assert_eq!( + component.format.font, + Some(Cow::Borrowed("minecraft:default")) + ); + } + + #[test] + fn test_resolve_scoreboard_noresolutor() { + let component = TextComponent::scoreboard("@p", "score"); + let resolved = component.resolve(&NoResolutor); + assert!( + matches!(resolved.content, Content::Text { text } if text.contains("[Score: score]")) + ); + } + + #[test] + fn test_resolve_entity_noresolutor() { + let component = TextComponent::entity("@a", None); + let resolved = component.resolve(&NoResolutor); + assert!( + matches!(resolved.content, Content::Text { text } if text.contains("[Entity: @a]")) + ); + } + + #[test] + fn test_build_plain_text() { + let component = TextComponent::plain("Hello world"); + let result = component.build(&NoResolutor, StringTarget); + assert_eq!(result, "Hello world"); + } +} diff --git a/src/nbt.rs b/src/nbt.rs index ab9ef67..fb1b1b1 100644 --- a/src/nbt.rs +++ b/src/nbt.rs @@ -1,7 +1,7 @@ #[cfg(feature = "custom")] use crate::custom::Payload; use crate::{ - Modifier, TextComponent, + Modifier, RawTextComponent, content::{Content, Object}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent, Interactivity}, @@ -15,12 +15,12 @@ use std::ops::Deref as _; pub struct NbtBuilder; -impl BuildTarget for NbtBuilder { +impl<'a> BuildTarget<'a> for NbtBuilder { type Result = NbtTag; - fn build_component( + fn build_component + ?Sized>( &self, resolutor: &R, - component: &TextComponent, + component: &RawTextComponent<'a>, ) -> NbtTag { let mut items = vec![]; component.content.to_compound(&mut items, self, resolutor); @@ -46,8 +46,8 @@ impl BuildTarget for NbtBuilder { } } -impl TextComponent { - pub fn nbt_display>(tag: T) -> Self { +impl<'a> RawTextComponent<'a> { + pub fn nbt_display(tag: impl Into) -> Self { let tag = tag.into(); match tag { NbtTag::Byte(n) => n @@ -84,7 +84,7 @@ impl TextComponent { children.push(", ".into()); } } - children.push(TextComponent::plain("]")); + children.push(RawTextComponent::plain("]")); component.add_children(children) } NbtTag::String(string) => { @@ -94,12 +94,12 @@ impl TextComponent { let component = "[".color(Color::White); let mut children = vec![]; for (i, tag) in nbt_list.as_nbt_tags().into_iter().enumerate() { - children.push(TextComponent::nbt_display(tag)); + children.push(RawTextComponent::nbt_display(tag)); if i + 1 != nbt_list.as_nbt_tags().len() { children.push(", ".into()); } } - children.push(TextComponent::plain("]")); + children.push(RawTextComponent::plain("]")); component.add_children(children) } NbtTag::Compound(compound) => { @@ -111,14 +111,14 @@ impl TextComponent { children.push(name.to_string().color(Color::Aqua)); children.push(": ".into()); } else if len == 1 { - return TextComponent::nbt_display(tag); + return RawTextComponent::nbt_display(tag); } - children.push(TextComponent::nbt_display(tag)); + children.push(RawTextComponent::nbt_display(tag)); if i + 1 != len { children.push(", ".into()); } } - children.push(TextComponent::plain("}")); + children.push(RawTextComponent::plain("}")); component.add_children(children) } NbtTag::IntArray(items) => { @@ -132,7 +132,7 @@ impl TextComponent { children.push(", ".into()); } } - children.push(TextComponent::plain("]")); + children.push(RawTextComponent::plain("]")); component.add_children(children) } NbtTag::LongArray(items) => { @@ -150,7 +150,7 @@ impl TextComponent { children.push(", ".into()); } } - children.push(TextComponent::plain("]")); + children.push(RawTextComponent::plain("]")); component.add_children(children) } } @@ -264,8 +264,8 @@ impl ToSNBT for NbtTag { } } -impl Content { - fn to_compound( +impl<'a> Content<'a> { + fn to_compound + ?Sized>( &self, compound: &mut Vec<(Mutf8String, NbtTag)>, target: &NbtBuilder, @@ -347,7 +347,7 @@ impl Content { } } -impl Format { +impl<'a> Format<'a> { fn to_compound(&self, compound: &mut Vec<(Mutf8String, NbtTag)>) { if let Some(color) = &self.color { compound.push(( @@ -399,8 +399,8 @@ impl Format { } } -impl Interactivity { - fn to_compound( +impl<'a> Interactivity<'a> { + fn to_compound + ?Sized>( &self, resolutor: &R, compound: &mut Vec<(Mutf8String, NbtTag)>, @@ -420,8 +420,8 @@ impl Interactivity { } } -impl HoverEvent { - fn to_nbt_tag(&self, resolutor: &R) -> NbtTag { +impl<'a> HoverEvent<'a> { + fn to_nbt_tag + ?Sized>(&self, resolutor: &R) -> NbtTag { match self { HoverEvent::ShowText { value } => NbtTag::Compound(NbtCompound::from_values(vec![ ("action".into(), NbtTag::String("show_text".into())), @@ -466,7 +466,7 @@ impl HoverEvent { } } -impl ClickEvent { +impl<'a> ClickEvent<'a> { fn to_nbt_tag(&self) -> NbtTag { let mut values = vec![]; match &self { @@ -507,19 +507,19 @@ impl ClickEvent { } } -impl ToNbtTag for TextComponent { +impl<'a> ToNbtTag for RawTextComponent<'a> { fn to_nbt_tag(self) -> NbtTag { NbtBuilder.build_component(&NoResolutor, &self) } } -impl ToNbtTag for &TextComponent { +impl<'a> ToNbtTag for &RawTextComponent<'a> { fn to_nbt_tag(self) -> NbtTag { NbtBuilder.build_component(&NoResolutor, self) } } -impl FromNbtTag for TextComponent { +impl<'a> FromNbtTag for RawTextComponent<'a> { fn from_nbt_tag(tag: simdnbt::borrow::NbtTag) -> Option { - TextComponent::from_nbt(&tag.to_owned()) + RawTextComponent::from_nbt(&tag.to_owned()) } } diff --git a/src/parse/mod.rs b/src/parse/mod.rs index 9864e08..e75dfdc 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -1,7 +1,7 @@ #[cfg(feature = "custom")] use crate::custom::{CustomData, Payload}; use crate::{ - Modifier, TextComponent, + Modifier, RawTextComponent, content::{Content, NbtSource, Object, ObjectPlayer, PlayerProperties, Resolvable}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent, Interactivity}, @@ -56,13 +56,16 @@ impl Display for SnbtError { pub type SnbtResult = Result; -impl TextComponent { - pub fn from_snbt(string: &str) -> SnbtResult { +impl<'a> RawTextComponent<'a> { + pub fn from_snbt(string: &'a str) -> SnbtResult> { parse_body(None, &mut string.chars().peekable()) } } -fn parse_body(first: Option, chars: &mut Peekable) -> SnbtResult { +fn parse_body<'a>( + first: Option, + chars: &mut Peekable, +) -> SnbtResult> { let char = match first { Some(first) => first, None => { @@ -82,8 +85,8 @@ fn parse_body(first: Option, chars: &mut Peekable) -> SnbtResult return parse_string('"', chars).map(TextComponent::plain), - '\'' => return parse_string('\'', chars).map(TextComponent::plain), + '"' => return parse_string('"', chars).map(RawTextComponent::plain), + '\'' => return parse_string('\'', chars).map(RawTextComponent::plain), '[' => { let mut components = parse_vec(chars)?.into_iter(); let first = components.next().ok_or(SnbtError::Required( @@ -123,7 +126,7 @@ fn parse_string(opener: char, chars: &mut Peekable) -> SnbtResult Err(SnbtError::EndedAbruptely(line!())) } -fn parse_vec(chars: &mut Peekable) -> SnbtResult> { +fn parse_vec<'a>(chars: &mut Peekable) -> SnbtResult>> { let mut component = vec![]; let Ok(child) = parse_body(None, chars) else { return Err(SnbtError::UnfinishedComponent(line!())); @@ -145,14 +148,14 @@ fn parse_vec(chars: &mut Peekable) -> SnbtResult> { Err(SnbtError::EndedAbruptely(line!())) } -struct CompoundParts { +struct CompoundParts<'a> { pub content: String, pub object: String, - pub contents: [Option; 9], + pub contents: [Option>; 9], pub nbt: String, - pub nbt_sources: [Option; 3], + pub nbt_sources: [Option>; 3], } -impl CompoundParts { +impl<'a> CompoundParts<'a> { pub fn new() -> Self { CompoundParts { content: String::new(), @@ -164,7 +167,7 @@ impl CompoundParts { } } -fn parse_compound(chars: &mut Peekable) -> SnbtResult { +fn parse_compound<'a>(chars: &mut Peekable) -> SnbtResult> { let mut compound = CompoundParts::new(); let mut format = Format::new(); let mut interactions = Interactivity::new(); @@ -177,7 +180,7 @@ fn parse_compound(chars: &mut Peekable) -> SnbtResult { } match char { '}' => { - return Ok(TextComponent { + return Ok(RawTextComponent { content: retrieve_content(compound)?, children, format, @@ -514,7 +517,7 @@ fn match_content( } } -fn parse_scoreboard(chars: &mut Peekable) -> SnbtResult { +fn parse_scoreboard<'a>(chars: &mut Peekable) -> SnbtResult> { let mut selector = None; let mut objective = None; let mut name = String::new(); @@ -578,7 +581,7 @@ fn parse_scoreboard(chars: &mut Peekable) -> SnbtResult { } Err(SnbtError::EndedAbruptely(line!())) } -fn parse_player(chars: &mut Peekable) -> SnbtResult { +fn parse_player<'a>(chars: &mut Peekable) -> SnbtResult> { let mut player = ObjectPlayer { name: None, id: None, @@ -675,7 +678,7 @@ fn parse_player(chars: &mut Peekable) -> SnbtResult { } Err(SnbtError::EndedAbruptely(line!())) } -fn parse_player_property(chars: &mut Peekable) -> SnbtResult { +fn parse_player_property<'a>(chars: &mut Peekable) -> SnbtResult> { let mut property = PlayerProperties { name: Cow::Borrowed("-None-"), value: Cow::Borrowed("-None-"), @@ -744,7 +747,7 @@ fn parse_player_property(chars: &mut Peekable) -> SnbtResult) -> SnbtResult { +fn parse_custom<'a>(chars: &mut Peekable) -> SnbtResult> { let mut id = None; let mut name = String::new(); let mut in_name = true; @@ -802,7 +805,7 @@ fn parse_custom(chars: &mut Peekable) -> SnbtResult { Err(SnbtError::EndedAbruptely(line!())) } -fn retrieve_content(compound: CompoundParts) -> SnbtResult { +fn retrieve_content<'a>(compound: CompoundParts<'a>) -> SnbtResult> { let mut error = SnbtError::MissingContent; let pos = match compound.content.as_str() { "text" => Some(0), @@ -842,11 +845,11 @@ fn retrieve_content(compound: CompoundParts) -> SnbtResult { } Err(error) } -fn match_content_type( - mut content: Content, +fn match_content_type<'a>( + mut content: Content<'a>, nbt: &str, - nbt_sources: &[Option; 3], -) -> SnbtResult { + nbt_sources: &[Option>; 3], +) -> SnbtResult> { match &mut content { Content::Translate(msg) => { if !msg.key.is_empty() { @@ -1083,7 +1086,7 @@ fn match_interactions( } } -fn parse_click(chars: &mut Peekable) -> SnbtResult { +fn parse_click<'a>(chars: &mut Peekable) -> SnbtResult> { let mut action = String::new(); let mut events = [None, None, None, None, None, None, None, None]; let mut name = String::new(); @@ -1188,7 +1191,7 @@ fn parse_click(chars: &mut Peekable) -> SnbtResult { }) } "command" => { - let command: Cow<'static, str> = + let command: Cow<'a, str> = Cow::Owned(parse_string(next, chars)?); events[2] = Some(ClickEvent::RunCommand { command: command.clone(), @@ -1237,7 +1240,7 @@ fn parse_click(chars: &mut Peekable) -> SnbtResult { } Err(SnbtError::EndedAbruptely(line!())) } -fn parse_hover(chars: &mut Peekable) -> SnbtResult { +fn parse_hover<'a>(chars: &mut Peekable) -> SnbtResult> { let mut action = String::new(); let mut events = [None, None, None]; let mut name = String::new(); @@ -1320,8 +1323,7 @@ fn parse_hover(chars: &mut Peekable) -> SnbtResult { } "id" => match next { '\'' | '"' => { - let new_id: Cow<'static, str> = - Cow::Owned(parse_string(next, chars)?); + let new_id: Cow<'a, str> = Cow::Owned(parse_string(next, chars)?); match &mut events[1] { Some(HoverEvent::ShowItem { id, .. }) => { *id = new_id.clone(); diff --git a/src/parse/nbt.rs b/src/parse/nbt.rs index 92e076f..da05727 100644 --- a/src/parse/nbt.rs +++ b/src/parse/nbt.rs @@ -53,7 +53,7 @@ impl TextComponent { } } -impl Content { +impl<'a> Content<'a> { fn from_compound(compound: &NbtCompound) -> Option { if let Some(tag) = compound.get("text") && let NbtTag::String(text) = tag @@ -297,7 +297,7 @@ impl Content { } } -impl Format { +impl<'a> Format<'a> { fn from_compound(compound: &NbtCompound) -> Self { let mut format = Format::new(); if let Some(tag) = compound.get("color") @@ -425,7 +425,7 @@ impl Format { } } -impl Interactivity { +impl<'a> Interactivity<'a> { fn from_compound(compound: &NbtCompound) -> Self { let mut interaction = Interactivity::new(); if let Some(tag) = compound.get("insertion") @@ -448,7 +448,7 @@ impl Interactivity { } } -impl HoverEvent { +impl<'a> HoverEvent<'a> { fn from_compound(compound: &NbtCompound) -> Option { let tag = compound.get("action")?; if let NbtTag::String(event) = tag { @@ -529,7 +529,7 @@ impl HoverEvent { } } -impl ClickEvent { +impl<'a> ClickEvent<'a> { fn from_compound(compound: &NbtCompound) -> Option { let tag = compound.get("action")?; if let NbtTag::String(event) = tag { @@ -602,7 +602,7 @@ impl ClickEvent { } #[cfg(feature = "custom")] -impl CustomData { +impl<'a> CustomData<'a> { fn from_compound(compound: &NbtCompound) -> Option { use crate::custom::{CustomData, Payload}; diff --git a/src/resolving.rs b/src/resolving.rs index 93fbb29..de26e1f 100644 --- a/src/resolving.rs +++ b/src/resolving.rs @@ -3,19 +3,61 @@ use std::sync::Arc; #[cfg(feature = "custom")] use crate::custom::CustomData; use crate::{ - TextComponent, + RawTextComponent, content::{Content, Resolvable}, }; -/// Recommendation: Implement this on the World and Player -pub trait TextResolutor { - fn resolve_other(&self, content: &Content) -> TextComponent { - TextComponent::from(content.clone()) +/// Trait for resolving dynamic content within a text component. +/// +/// This trait provides the necessary hooks to replace placeholders (like scoreboards, +/// entity selectors, NBT paths, translations, and custom data) with actual +/// `RawTextComponent` trees. It is intended to be implemented on game-world objects +/// such as a player or the server world, where the resolution logic can access +/// live data. +/// +/// # Recommendation +/// Implement this on the `World` and `Player` types in your application. +pub trait TextResolutor<'a> { + /// Fallback resolution for any `Content` variant that is not explicitly handled. + /// + /// By default it clones the content as-is, converting it into a plain component. + /// Override this method if you need special handling for other content types. + fn resolve_other(&self, content: &Content<'a>) -> RawTextComponent<'a> { + RawTextComponent::from(content.clone()) } - fn resolve_content(&self, resolvable: &Resolvable) -> TextComponent; + + /// Resolves a `Resolvable` variant into a `RawTextComponent`. + /// + /// This method is called for scoreboards, entity selectors, and NBT paths. + /// The implementation should query the game state and return a component + /// representing the resolved value (e.g., a score value, an entity name, + /// or a formatted NBT tag). + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a>; + + /// Resolves a custom data block into an optional `RawTextComponent`. + /// + /// Only available when the `custom` feature is enabled. Return `None` if + /// the custom ID is not recognized or cannot be resolved. #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData) -> Option; + fn resolve_custom(&self, data: &CustomData<'a>) -> Option>; + + /// Translates a given translation key into a human-readable string. + /// + /// Returns `None` if the key is unknown. The returned string may contain + /// parameter placeholders (`%s` or `%n$s`) that will be processed by + /// `split_translation`. fn translate(&self, key: &str) -> Option; + + /// Splits a translation string into segments and parameter indices. + /// + /// This method parses placeholders like `%s` (sequential) and `%n$s` + /// (positional) and returns a vector of `(text, param_index)`. The + /// `text` part is the literal substring, and `param_index` is the + /// 1‑based argument position (or 0 for trailing text). The default + /// implementation handles up to 8 positional parameters and any number + /// of sequential ones. + /// + /// You may override this if your translation format differs. fn split_translation(&self, text: String) -> Vec<(String, usize)> { let mut positions = vec![(0, 0, 0), (text.len(), 0, 0)]; for i in 1..=8 { @@ -39,13 +81,13 @@ pub trait TextResolutor { } } -impl TextResolutor for Arc { - fn resolve_content(&self, resolvable: &Resolvable) -> TextComponent { +impl<'a, T: TextResolutor<'a>> TextResolutor<'a> for Arc { + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { (**self).resolve_content(resolvable) } #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData) -> Option { + fn resolve_custom(&self, data: &CustomData<'a>) -> Option> { (**self).resolve_custom(data) } @@ -58,23 +100,29 @@ impl TextResolutor for Arc { } } +/// A `TextResolutor` that does no actual resolution. +/// +/// It returns stub text for scoreboards, entity selectors, NBT paths, and custom +/// data, and never translates any key. Useful for testing or when only the +/// static structure of a component is needed. pub struct NoResolutor; -impl TextResolutor for NoResolutor { - fn resolve_content(&self, resolvable: &Resolvable) -> TextComponent { + +impl<'a> TextResolutor<'a> for NoResolutor { + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { match resolvable { Resolvable::Scoreboard { objective, .. } => { - TextComponent::plain(format!("[Score: {objective}]")) + RawTextComponent::plain(format!("[Score: {objective}]")) } Resolvable::Entity { selector, .. } => { - TextComponent::plain(format!("[Entity: {selector}]")) + RawTextComponent::plain(format!("[Entity: {selector}]")) } - Resolvable::NBT { path, .. } => TextComponent::plain(format!("[Nbt: {path}]")), + Resolvable::NBT { path, .. } => RawTextComponent::plain(format!("[Nbt: {path}]")), } } #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &crate::custom::CustomData) -> Option { - Some(TextComponent::plain(data.id.clone())) + fn resolve_custom(&self, data: &crate::custom::CustomData<'a>) -> Option> { + Some(RawTextComponent::plain(data.id.clone())) } fn translate(&self, _key: &str) -> Option { @@ -82,8 +130,29 @@ impl TextResolutor for NoResolutor { } } -impl TextComponent { - pub fn build( +impl<'a> RawTextComponent<'a> { + /// Builds the component into a target format after full resolution. + /// + /// This method first resolves all dynamic content using the given `resolutor`, + /// then uses the `target` builder to convert the resolved component tree into + /// the desired output type (e.g., plain `String`, coloured terminal output, + /// NBT, JSON, etc.). + /// + /// # Example + /// ``` + /// # use text_components::RawTextComponent; + /// # use text_components::resolving::{TextResolutor, BuildTarget, NoResolutor}; + /// # struct MyTarget; + /// # impl<'a> BuildTarget<'a> for MyTarget { + /// # type Result = String; + /// # fn build_component + ?Sized>(&self, _: &R, c: &RawTextComponent<'a>) -> String { + /// # "built".to_string() + /// # } + /// # } + /// let component = RawTextComponent::plain("Hello"); + /// let result = component.build(&NoResolutor, MyTarget); + /// ``` + pub fn build + ?Sized, S: BuildTarget<'a>>( &self, resolutor: &R, target: S, @@ -91,12 +160,21 @@ impl TextComponent { target.build_component(resolutor, &self.resolve(resolutor)) } - pub fn resolve(&self, resolutor: &R) -> TextComponent { + /// Resolves all dynamic parts of the component recursively. + /// + /// This replaces `Resolvable` and `Custom` content with actual components + /// obtained from the `resolutor`, and also resolves arguments inside + /// `TranslatedMessage`, separators for entity/NBT resolvables, and children. + /// Formatting and interactivity are merged appropriately. + /// + /// The returned component is fully static (no more `Resolvable` leaves) + /// and can be serialized or built without further resolution. + pub fn resolve + ?Sized>(&self, resolutor: &R) -> RawTextComponent<'a> { let mut component = match &self.content { #[cfg(feature = "custom")] Content::Custom(data) => resolutor .resolve_custom(data) - .unwrap_or(TextComponent::new()), + .unwrap_or(RawTextComponent::new()), Content::Resolvable(resolvable) => resolutor.resolve_content(resolvable), content => resolutor.resolve_other(content), }; @@ -106,7 +184,7 @@ impl TextComponent { message.args = message.args.as_ref().map(|args| { args.iter() .map(|arg| arg.resolve(resolutor)) - .collect::>() + .collect::>() .into_boxed_slice() }); } @@ -133,11 +211,24 @@ impl TextComponent { } } -pub trait BuildTarget { +/// A target format for building a resolved text component. +/// +/// Implement this trait to convert a resolved `RawTextComponent` tree into +/// a concrete representation, such as a plain `String`, an ANSI‑coloured string, +/// an NBT tag, or a JSON value. +/// +/// # Type Parameter +/// * `Result` – The type produced by the builder (e.g., `String`, `NbtTag`). +pub trait BuildTarget<'a> { + /// The type produced by this builder. type Result; - fn build_component( + + /// Converts a single resolved component into the target representation. + /// + /// The method is called recursively for the whole component tree. + fn build_component + ?Sized>( &self, resolutor: &R, - component: &TextComponent, + component: &RawTextComponent<'a>, ) -> Self::Result; } diff --git a/src/translation.rs b/src/translation.rs index fa5a7b0..08d735f 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -1,28 +1,31 @@ -use crate::TextComponent; +use crate::RawTextComponent; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "databake", derive(::databake::Bake))] +#[cfg_attr(feature = "databake", databake(path = text_components::translation))] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] -pub struct TranslatedMessage { +pub struct TranslatedMessage<'a> { #[cfg_attr(feature = "serde", serde(rename = "translate"))] - pub key: Cow<'static, str>, + pub key: Cow<'a, str>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub fallback: Option>, + pub fallback: Option>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", rename = "with", default) )] - pub args: Option>, + pub args: Option]>>, } -impl TranslatedMessage { +impl<'a> TranslatedMessage<'a> { /// Creates a new `TranslatedMessage` without fallback. /// ### Warning /// Using this method directly is discouraged. /// Please use a compiled [Translation] instead. - pub const fn new(key: &'static str, args: Option>) -> Self { + pub const fn new(key: &'a str, args: Option]>>) -> Self { Self { key: Cow::Borrowed(key), args, @@ -31,42 +34,42 @@ impl TranslatedMessage { } #[inline] - pub fn component(self) -> TextComponent { - TextComponent::translated(self) + pub fn component(self) -> RawTextComponent<'a> { + RawTextComponent::translated(self) } #[inline] - pub fn component_fallback>>(mut self, fallback: F) -> TextComponent { + pub fn component_fallback(mut self, fallback: impl Into>) -> RawTextComponent<'a> { self.fallback = Some(fallback.into()); - TextComponent::translated(self) + RawTextComponent::translated(self) } } -impl From for TextComponent { - fn from(value: TranslatedMessage) -> Self { +impl<'a> From> for RawTextComponent<'a> { + fn from(value: TranslatedMessage<'a>) -> Self { value.component() } } -pub struct Translation(pub &'static str); +pub struct Translation<'a, const ARGS: usize>(pub &'a str); -impl Translation<0> { +impl<'a> Translation<'a, 0> { /// Creates a new `TranslatedMessage` with no arguments. #[must_use] - pub const fn msg(&self) -> TranslatedMessage { + pub const fn msg(&self) -> TranslatedMessage<'_> { TranslatedMessage::new(self.0, None) } } -impl Translation { +impl<'a, const ARGS: usize> Translation<'a, ARGS> { /// Creates a new `TranslatedMessage` with the given arguments. #[must_use] - pub fn message(&self, args: [impl Into; ARGS]) -> TranslatedMessage { + pub fn message(&self, args: [impl Into>; ARGS]) -> TranslatedMessage<'_> { TranslatedMessage::new(self.0, Some(Box::new(args.map(Into::into)))) } } -impl From<&Translation<0>> for TextComponent { - fn from(value: &Translation<0>) -> Self { +impl<'a> From<&'a Translation<'a, 0>> for RawTextComponent<'a> { + fn from(value: &'a Translation<'a, 0>) -> Self { value.msg().component() } } From 9224746089901410f2db45e0a537308e8059b9a7 Mon Sep 17 00:00:00 2001 From: MrMelther <73891959+DarkMrMelther@users.noreply.github.com> Date: Sat, 13 Jun 2026 02:04:43 +0200 Subject: [PATCH 2/3] Improvements (#2) * Small QOL improvements * Add the improvements to the README * Solutions * Update README.md * Update fmt.rs * Clippy god --- Cargo.lock | 19 ------- Cargo.toml | 7 ++- README.md | 16 ++++-- examples/main.rs | 4 +- examples/nbt.rs | 4 +- examples/serde.rs | 2 +- examples/snbt.rs | 2 +- src/build.rs | 1 + src/fmt.rs | 129 ++++++++++++++++++++++++---------------------- src/format.rs | 51 +++++++++--------- src/parse/mod.rs | 2 +- src/resolving.rs | 43 +++++++++++++++- 12 files changed, 160 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a52e124..73e0fc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,15 +87,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "colored" -version = "3.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" -dependencies = [ - "windows-sys", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -643,7 +634,6 @@ name = "text_components" version = "0.1.7" dependencies = [ "chrono", - "colored", "databake", "heck", "memchr", @@ -860,15 +850,6 @@ dependencies = [ "windows-link", ] -[[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 = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index e21d663..160b3be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,21 +33,20 @@ build = [ ] [dependencies] -colored = "3.1" rand = { version = "0.10", default-features = false, features = [ "std", "thread_rng", ] } serde = { version = "1.0", features = ["derive"], optional = true } simdnbt = { version = "0.10", optional = true } +uuid = { version = "1.23", features = ["v4", "serde"] } +supports-hyperlinks = "3.2.0" # Build dependencies heck = { version = "0.5", optional = true } proc-macro2 = { version = "1.0", optional = true } quote = { version = "1.0", optional = true } rustc-hash = { version = "2.1", optional = true } -serde_json = { version = "1.0", optional = true } -uuid = { version = "1.23", features = ["v4", "serde"] } -supports-hyperlinks = "3.2" +serde_json = { version = "1.0.149", optional = true } memchr = "2.8" smallvec = "1.15" ownable = { version = "1.0", optional = true } diff --git a/README.md b/README.md index cb96e79..1577617 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ component.resolve(resolutor).serialize(serializer); ### Displaying TextComponents -TextComponent implements Display for easy logging, as you can see, a component +TextComponent implements ToString for easy logging, as you can see, a component needs to be resolved before building it into any format, by default it uses a static reference to NoResolutor, but can be changed to a custom one with:\ (Resolutor must be static, or made inside the function call) @@ -55,12 +55,20 @@ reference to NoResolutor, but can be changed to a custom one with:\ set_display_resolutor(&Resolutor); ``` + A text component can be printed like a string like this: ```rs -println!("{}", component); -// With format (pretty): -println!("{:p}", component); +println!("{}", component.to_string()); +// With format (colorful): +println!("{}", component.log()); +``` + +If you want the log display format different to be able to parse it later, it can be done through `set_display_builder`, +which will need a function returning the built string, this is an example with the PrettyTextBuilder (the default one): + +```rs +set_display_builder(|component, resolutor| component.build(resolutor, PrettyTextBuilder)) ``` ### Roadmap diff --git a/examples/main.rs b/examples/main.rs index 8a1ac5f..e060816 100644 --- a/examples/main.rs +++ b/examples/main.rs @@ -166,6 +166,6 @@ fn main() { "\nNBT (SNBT):\ntellraw @a {}", component.build(&EmptyResolutor, NbtBuilder).to_snbt() ); - println!("\nText:\n{}", component); - println!("\nPretty Text:\n{:p}", component); + println!("\nText:\n{}", component.to_string()); + println!("\nPretty Text:\n{}", component.log()); } diff --git a/examples/nbt.rs b/examples/nbt.rs index 24d4077..0fe3fc3 100644 --- a/examples/nbt.rs +++ b/examples/nbt.rs @@ -24,7 +24,7 @@ fn main() -> Result<(), String> { "tellraw @p {}", component.build(&NoResolutor, NbtBuilder).to_snbt() ); - println!("{:p}", component); + println!("{}", component.log()); let nbt = "Holly molly I can get TextComponents from NBTs!" .color(Color::Red) @@ -41,6 +41,6 @@ fn main() -> Result<(), String> { let component = RawTextComponent::from_nbt(&nbt) .ok_or(String::from("Cannot recompose the TextComponent!"))?; println!("{:?}", component); - println!("{:p}", component); + println!("{}", component.log()); Ok(()) } diff --git a/examples/serde.rs b/examples/serde.rs index 4c8427d..b35202b 100644 --- a/examples/serde.rs +++ b/examples/serde.rs @@ -13,5 +13,5 @@ fn main() { }", ) .unwrap(); - println!("{:p}", component) + println!("{}", component.log()) } diff --git a/examples/snbt.rs b/examples/snbt.rs index b021e6c..bcbee73 100644 --- a/examples/snbt.rs +++ b/examples/snbt.rs @@ -18,7 +18,7 @@ fn main() { match component { Ok(component) => { println!("{:?}", component); - println!("{:p}", component) + println!("{}", component.log()) } Err(e) => eprintln!("{}", e), } diff --git a/src/build.rs b/src/build.rs index 033bd7e..0269e89 100644 --- a/src/build.rs +++ b/src/build.rs @@ -69,6 +69,7 @@ pub fn build_translations(path: &str) -> TokenStream { let const_name = Ident::new(&const_name_str, Span::call_site()); stream.extend(quote! { + #[doc = #text] pub static #const_name: Translation<#param_count> = Translation(#key); }); } diff --git a/src/fmt.rs b/src/fmt.rs index a382a07..c966dcd 100644 --- a/src/fmt.rs +++ b/src/fmt.rs @@ -1,15 +1,15 @@ use crate::{ - RawTextComponent, + RawTextComponent, TextComponent, content::{Content, Object}, format::{Color, Format}, interactivity::{ClickEvent, Interactivity}, resolving::{BuildTarget, NoResolutor, TextResolutor}, }; -use colored::{ColoredString, Colorize}; use rand::random_range; use std::{ borrow::Cow, - fmt::{self, Debug, Display, Formatter, Pointer}, + fmt::{self, Debug, Formatter}, + sync::OnceLock, }; use supports_hyperlinks::supports_hyperlinks; @@ -62,22 +62,19 @@ const OBFUSCATION_CHARS: [char; 822] = [ pub struct TextBuilder; impl TextBuilder { - fn stringify_content<'a, R: TextResolutor<'a> + ?Sized, S: BuildTarget<'a>>( + fn stringify_content<'a, R: TextResolutor<'a> + ?Sized, S: BuildTarget<'a, Result = String>>( target: &S, resolutor: &R, component: &RawTextComponent<'a>, - ) -> S::Result - where - S::Result: From + ToString + Display, - { + ) -> String { match &component.content { - Content::Text { text } => text.to_string().into(), + Content::Text { text } => text.to_string(), Content::Translate(message) => { let translated = match resolutor.translate(&message.key) { Some(t) => t, None => match &message.fallback { - Some(f) => return f.to_string().into(), - None => return format!("[Translation: {}]", message.key).into(), + Some(f) => return f.to_string(), + None => return format!("[Translation: {}]", message.key), }, }; let parts = resolutor.split_translation(translated); @@ -88,11 +85,7 @@ impl TextBuilder { format: component.format.clone(), ..RawTextComponent::new() }; - built_parts.push( - target - .build_component(resolutor, &component_part) - .to_string(), - ); + built_parts.push(target.build_component(resolutor, &component_part)); if pos != 0 && let Some(args) = &message.args && pos <= args.len() @@ -104,25 +97,25 @@ impl TextBuilder { format: arg.format.mix(&component.format), interactions: arg.interactions.clone(), }; - built_parts.push(target.build_component(resolutor, &arg_part).to_string()); + built_parts.push(target.build_component(resolutor, &arg_part)); } } - built_parts.concat().into() + built_parts.concat() } - Content::Keybind { keybind } => format!("[Keybind: {}]", keybind).into(), - Content::Object(Object::Atlas { sprite, .. }) => format!("[Object: {}]", sprite).into(), + Content::Keybind { keybind } => format!("[Keybind: {}]", keybind), + Content::Object(Object::Atlas { sprite, .. }) => format!("[Object: {}]", sprite), Content::Object(Object::Player { player, .. }) => { if let Some(name) = &player.name { - return format!("[Head: {}]", name).into(); + return format!("[Head: {}]", name); } if let Some(id) = &player.id { - return format!("[Head: {:?}]", id).into(); + return format!("[Head: {:?}]", id); } - String::from("[Head]").into() + String::from("[Head]") } - Content::Resolvable(_) => String::from("[Resolvable]").into(), // Just in case ;) + Content::Resolvable(_) => String::from("[Resolvable]"), // Just in case ;) #[cfg(feature = "custom")] - Content::Custom { .. } => String::from("[Custom]").into(), + Content::Custom { .. } => String::from("[Custom]"), } } } @@ -143,14 +136,23 @@ impl<'a> BuildTarget<'a> for TextBuilder { } } +const URL_START: &str = "\x1b]8;;"; +const URL_SEPARATOR: &str = "\x1b\\"; +const URL_END: &str = "\x1b]8;;\x1b\\"; +const BOLD: &str = "\x1b[1m"; +const ITALIC: &str = "\x1b[3m"; +const UNDERLINED: &str = "\x1b[4m"; +const STRIKETHROUGH: &str = "\x1b[9m"; +const BG_COLOR: &str = "\x1b[48;2;"; +const RESET: &str = "\x1b[0m"; pub struct PrettyTextBuilder; impl<'a> BuildTarget<'a> for PrettyTextBuilder { - type Result = ColoredString; + type Result = String; fn build_component + ?Sized>( &self, resolutor: &R, component: &RawTextComponent<'a>, - ) -> ColoredString { + ) -> String { let mut final_text = TextBuilder::stringify_content(self, resolutor, component); if let Content::Translate(_) = component.content { @@ -167,16 +169,15 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { format: child.format.mix(&component.format), interactions: child.interactions.clone(), }; - self.build_component(resolutor, &child).to_string() + self.build_component(resolutor, &child) }) .collect::>() .concat() - ) - .into(); + ); } if let Some(true) = component.format.obfuscated { - let obfuscated = final_text + final_text = final_text .chars() .map(|char| { if !char.is_whitespace() && !char.is_control() { @@ -185,35 +186,39 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { char }) .collect::(); - final_text = ColoredString::from(obfuscated); } if let Some(color) = &component.format.color { - final_text = color.colorize_text(final_text.to_string()); + color.colorize_text(&mut final_text); } if let Some(true) = component.format.bold { - final_text = final_text.bold(); + final_text.insert_str(0, BOLD); } if let Some(true) = component.format.italic { - final_text = final_text.italic(); + final_text.insert_str(0, ITALIC); } if let Some(true) = component.format.underlined { - final_text = final_text.underline(); + final_text.insert_str(0, UNDERLINED); } if let Some(true) = component.format.strikethrough { - final_text = final_text.strikethrough(); + final_text.insert_str(0, STRIKETHROUGH); } if let Some(color) = component.format.shadow_color { - final_text = final_text.on_truecolor( - ((color >> 16) & 0xFF) as u8, - ((color >> 8) & 0xFF) as u8, - (color & 0xFF) as u8, + final_text.insert_str( + 0, + &format!( + "{BG_COLOR}{};{};{}m", + ((color >> 16) & 0xFF) as u8, + ((color >> 8) & 0xFF) as u8, + (color & 0xFF) as u8, + ), ); } if supports_hyperlinks() && let Some(ClickEvent::OpenUrl { url }) = &component.interactions.click { - final_text = format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", url, final_text).into(); + final_text = format!("{URL_START}{}{URL_SEPARATOR}{}{URL_END}", url, final_text); } + final_text.push_str(RESET); format!( "{}{}", @@ -228,46 +233,46 @@ impl<'a> BuildTarget<'a> for PrettyTextBuilder { format: child.format.mix(&component.format), interactions: child.interactions.clone(), }; - self.build_component(resolutor, &child).to_string() + self.build_component(resolutor, &child) }) .collect::>() .concat() ) - .into() } } impl<'a> RawTextComponent<'a> { - pub fn to_plain + ?Sized>(&self, resolutor: &R) -> String { + pub fn to_plain + ?Sized>(&self, resolutor: &'a R) -> String { self.build(resolutor, TextBuilder) } - pub fn to_pretty + ?Sized>(&self, resolutor: &R) -> ColoredString { + pub fn to_pretty + ?Sized>(&self, resolutor: &'a R) -> String { self.build(resolutor, PrettyTextBuilder) } } -static mut DISPLAY_RESOLUTOR: &'static dyn for<'a> TextResolutor<'a> = - &NoResolutor as &'static dyn for<'a> TextResolutor<'a>; -static mut INITIALIZED: bool = false; +static DISPLAY_RESOLUTOR: OnceLock<&'static (dyn TextResolutor<'static> + Sync)> = OnceLock::new(); +type DisplayBuilder = fn(&TextComponent, &'static (dyn TextResolutor<'static> + Sync)) -> String; +static DISPLAY_BUILDER: OnceLock = OnceLock::new(); -pub fn set_display_resolutor TextResolutor<'a>>(resolutor: &'static T) { - unsafe { - if !INITIALIZED { - DISPLAY_RESOLUTOR = resolutor as &'static dyn for<'a> TextResolutor<'a>; - INITIALIZED = true; - } - } +pub fn set_display_resolutor(resolutor: &'static (impl TextResolutor<'static> + Sync)) { + DISPLAY_RESOLUTOR.get_or_init(|| resolutor); +} +pub fn set_display_builder(f: DisplayBuilder) { + DISPLAY_BUILDER.get_or_init(|| f); } -impl Display for RawTextComponent<'_> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", unsafe { self.to_plain(DISPLAY_RESOLUTOR) }) +#[allow(clippy::to_string_trait_impl)] +impl ToString for TextComponent { + fn to_string(&self) -> String { + self.to_plain(*DISPLAY_RESOLUTOR.get_or_init(|| &NoResolutor)) } } -impl<'a> Pointer for RawTextComponent<'a> { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{}", unsafe { self.to_pretty(DISPLAY_RESOLUTOR) }) +impl TextComponent { + pub fn log(&self) -> String { + let builder = DISPLAY_BUILDER + .get_or_init(|| |component, resolutor| component.build(resolutor, PrettyTextBuilder)); + builder(self, *DISPLAY_RESOLUTOR.get_or_init(|| &NoResolutor)) } } diff --git a/src/format.rs b/src/format.rs index c2d77cf..aa3978c 100644 --- a/src/format.rs +++ b/src/format.rs @@ -1,4 +1,3 @@ -use colored::{ColoredString, Colorize}; use std::{borrow::Cow, fmt::Display}; #[derive(Clone, PartialEq, Eq, Hash)] @@ -217,28 +216,34 @@ impl Color { } None } - - pub fn colorize_text(&self, text: impl Into) -> ColoredString { - let text = text.into(); - match self { - Color::Black => text.black(), - Color::DarkBlue => text.blue(), - Color::DarkGreen => text.green(), - Color::DarkAqua => text.cyan(), - Color::DarkRed => text.red(), - Color::DarkPurple => text.magenta(), - Color::Gold => text.yellow(), - Color::Gray => text.white(), - Color::DarkGray => text.bright_black(), - Color::Blue => text.bright_blue(), - Color::Green => text.bright_green(), - Color::Aqua => text.bright_cyan(), - Color::Red => text.bright_red(), - Color::LightPurple => text.bright_magenta(), - Color::Yellow => text.bright_yellow(), - Color::White => text.bright_white(), - Color::Rgb(r, g, b) => text.truecolor(*r, *g, *b), - } + pub fn colorize_text(&self, text: &mut String) { + let rgb = if let Color::Rgb(r, g, b) = self { + format!("\x1b[38;2;{r};{g};{b}m") + } else { + String::new() + }; + text.insert_str( + 0, + match self { + Color::Black => "\x1b[30m", + Color::DarkBlue => "\x1b[34m", + Color::DarkGreen => "\x1b[32m", + Color::DarkAqua => "\x1b[36m", + Color::DarkRed => "\x1b[31m", + Color::DarkPurple => "\x1b[35m", + Color::Gold => "\x1b[33m", + Color::Gray => "\x1b[37m", + Color::DarkGray => "\x1b[90m", + Color::Blue => "\x1b[94m", + Color::Green => "\x1b[92m", + Color::Aqua => "\x1b[96m", + Color::Red => "\x1b[91m", + Color::LightPurple => "\x1b[95m", + Color::Yellow => "\x1b[93m", + Color::White => "\x1b[97m", + Color::Rgb(..) => &rgb, + }, + ); } } diff --git a/src/parse/mod.rs b/src/parse/mod.rs index e75dfdc..f9d4191 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -57,7 +57,7 @@ impl Display for SnbtError { pub type SnbtResult = Result; impl<'a> RawTextComponent<'a> { - pub fn from_snbt(string: &'a str) -> SnbtResult> { + pub fn from_snbt(string: &str) -> SnbtResult> { parse_body(None, &mut string.chars().peekable()) } } diff --git a/src/resolving.rs b/src/resolving.rs index de26e1f..30aae57 100644 --- a/src/resolving.rs +++ b/src/resolving.rs @@ -159,6 +159,21 @@ impl<'a> RawTextComponent<'a> { ) -> S::Result { target.build_component(resolutor, &self.resolve(resolutor)) } + pub fn batch_build<'b, R: TextResolutor<'a> + ?Sized, S: BuildTarget<'a>>( + &self, + resolutors: Vec<&'b R>, + target: fn() -> S, + ) -> Vec<(&'b R, S::Result)> { + resolutors + .into_iter() + .map(|resolutor| { + ( + resolutor, + target().build_component(resolutor, &self.resolve(resolutor)), + ) + }) + .collect() + } /// Resolves all dynamic parts of the component recursively. /// @@ -209,6 +224,15 @@ impl<'a> RawTextComponent<'a> { component } + pub fn batch_resolve<'b, R: TextResolutor<'a> + ?Sized>( + &self, + resolutors: Vec<&'b R>, + ) -> Vec<(&'b R, RawTextComponent<'a>)> { + resolutors + .into_iter() + .map(|resolutor| (resolutor, self.resolve(resolutor))) + .collect() + } } /// A target format for building a resolved text component. @@ -230,5 +254,22 @@ pub trait BuildTarget<'a> { &self, resolutor: &R, component: &RawTextComponent<'a>, - ) -> Self::Result; + ) -> Self::Result + where + Self: Sized; +} + +impl<'a, T: BuildTarget<'a>> BuildTarget<'a> for Box { + type Result = T::Result; + + fn build_component + ?Sized>( + &self, + resolutor: &R, + component: &RawTextComponent<'a>, + ) -> Self::Result + where + Self: Sized, + { + (**self).build_component(resolutor, component) + } } From 1c1ecdcf847d93803510e43d167f0cdc3f8c5735 Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 13 Jun 2026 17:33:07 +0300 Subject: [PATCH 3/3] Initial --- Cargo.toml | 5 +- README.md | 2 +- src/lib.rs | 11 + src/minimessage.rs | 834 +++++++++++++++++++++++++++++++++++++++ src/minimessage_tests.rs | 793 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1642 insertions(+), 3 deletions(-) create mode 100644 src/minimessage.rs create mode 100644 src/minimessage_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 160b3be..658ca1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ nbt = ["dep:simdnbt"] serde = ["dep:serde"] ownable = ["dep:ownable"] databake = ["dep:databake"] +minimessage = ["ownable"] build = [ "dep:heck", "dep:proc-macro2", @@ -40,13 +41,13 @@ rand = { version = "0.10", default-features = false, features = [ serde = { version = "1.0", features = ["derive"], optional = true } simdnbt = { version = "0.10", optional = true } uuid = { version = "1.23", features = ["v4", "serde"] } -supports-hyperlinks = "3.2.0" +supports-hyperlinks = "3.2" # Build dependencies heck = { version = "0.5", optional = true } proc-macro2 = { version = "1.0", optional = true } quote = { version = "1.0", optional = true } rustc-hash = { version = "2.1", optional = true } -serde_json = { version = "1.0.149", optional = true } +serde_json = { version = "1.0", optional = true } memchr = "2.8" smallvec = "1.15" ownable = { version = "1.0", optional = true } diff --git a/README.md b/README.md index 1577617..725bf33 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ set_display_builder(|component, resolutor| component.build(resolutor, PrettyText - [x] Terminal integration - [x] Serde integration - [x] SimdNbt integration -- [ ] MiniMessage integration +- [x] MiniMessage integration - [ ] Extensibility integration ### Test diff --git a/src/lib.rs b/src/lib.rs index e7978d8..85f5b48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,11 @@ pub mod custom; pub mod fmt; pub mod format; pub mod interactivity; +#[cfg(feature = "minimessage")] +pub mod minimessage; +#[cfg(feature = "minimessage")] +#[cfg(test)] +mod minimessage_tests; #[cfg(feature = "nbt")] pub mod nbt; pub mod parse; @@ -313,6 +318,12 @@ impl<'a> RawTextComponent<'a> { interactions: Interactivity::new(), } } + + #[cfg(feature = "minimessage")] + pub fn minimessage(text: impl Into<&'a str>) -> RawTextComponent<'a> { + use crate::minimessage::Parser; + Parser::parse(text.into()) + } } impl<'a> Default for RawTextComponent<'a> { diff --git a/src/minimessage.rs b/src/minimessage.rs new file mode 100644 index 0000000..1b1ae6d --- /dev/null +++ b/src/minimessage.rs @@ -0,0 +1,834 @@ +use smallvec::SmallVec; + +use crate::{ + RawTextComponent, TextComponent, + content::{Content, NbtSource, Object, ObjectPlayer, Resolvable}, + format::{Color, Format}, + interactivity::{ClickEvent, HoverEvent}, + translation::TranslatedMessage, +}; +use std::borrow::Cow; + +#[cfg(feature = "custom")] +use crate::custom::{CustomData, Payload}; + +pub(super) fn parse(input: &str) -> TextComponent { + parse_raw(input).into_owned() +} + +pub(super) fn parse_raw<'a>(input: &'a str) -> RawTextComponent<'a> { + Parser::parse(input) +} + +fn new_component<'a>(content: Content<'a>) -> RawTextComponent<'a> { + RawTextComponent { + content, + ..Default::default() + } +} + +fn join_with_colon<'a>(args: &[Cow<'a, str>]) -> Cow<'a, str> { + if args.is_empty() { + return Cow::Borrowed(""); + } + if args.len() == 1 { + return args[0].clone(); + } + let mut result = String::new(); + for a in args { + if !result.is_empty() { + result.push(':'); + } + result.push_str(a.as_ref()); + } + Cow::Owned(result) +} + +pub(super) struct Parser<'a> { + nodes: Vec>, + children: Vec>, + stack: Vec<(usize, Cow<'a, str>)>, +} + +impl<'a> Parser<'a> { + pub fn parse(input: &'a str) -> RawTextComponent<'a> { + let mut parser = Parser { + nodes: vec![RawTextComponent::new()], + children: vec![Vec::new()], + stack: vec![(0, Cow::Borrowed(""))], + }; + let len = input.len(); + let bytes = input.as_bytes(); + let mut i = 0; + + while i < len { + let start = i; + let next_tag = memchr::memchr(b'<', &bytes[i..]); + if let Some(offset) = next_tag { + i += offset; + } else { + i = len; + } + if i > start { + let text = unescape_text(&input[start..i]); + if !text.is_empty() { + let comp = RawTextComponent::plain(text); + let parent = parser.stack.last().unwrap().0; + parser.add_child_node(parent, comp); + } + } + if next_tag.is_none() { + break; + } + i += 1; + if i >= len { + break; + } + if bytes[i] == b'/' { + i += 1; + let end = i; + while i < len && bytes[i] != b'>' { + i += 1; + } + let tag_name = &input[end..i]; + if i < len { + i += 1; + } + parser.close_tag(tag_name); + continue; + } + let tag_start = i; + while i < len { + match bytes[i] { + b'>' | b':' | b'/' => break, + _ => i += 1, + } + } + let tag_name = &input[tag_start..i]; + let mut args = SmallVec::new(); + let mut self_closing = false; + + if i < len && bytes[i] == b':' { + i += 1; + args = split_args(input, &mut i, len); + } + if i < len && bytes[i] == b'/' { + self_closing = true; + i += 1; + } + if i < len && bytes[i] == b'>' { + i += 1; + } else { + while i < len && bytes[i] != b'>' { + i += 1; + } + if i < len { + i += 1; + } + } + + parser.process_open_tag(tag_name, args, self_closing); + } + + parser.finish() + } + + fn add_child_node(&mut self, parent: usize, child: RawTextComponent<'a>) -> usize { + if let Content::Text { text: child_text } = &child.content + && child.format == Format::new() + && child.interactions == Default::default() + && let Some(&last_idx) = self.children[parent].last() + { + let last_node = &mut self.nodes[last_idx]; + if let Content::Text { text: last_text } = &mut last_node.content + && last_node.format == Format::new() + && last_node.interactions == Default::default() + { + last_text.to_mut().push_str(child_text); + return last_idx; + } + } + + let idx = self.nodes.len(); + self.nodes.push(child); + self.children.push(Vec::new()); + self.children[parent].push(idx); + idx + } + + fn push_tag_to_stack( + &mut self, + parent: usize, + comp: RawTextComponent<'a>, + tag_name: Option>, + self_closing: bool, + ) -> usize { + let idx = self.add_child_node(parent, comp); + if !self_closing && let Some(name) = tag_name { + self.stack.push((idx, name)); + } + idx + } + + fn push_format_tag( + &mut self, + parent: usize, + format: Format<'a>, + self_closing: bool, + tag: &'a str, + ) -> usize { + let comp = RawTextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + format, + ..Default::default() + }; + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed(tag)), self_closing) + } + + fn close_tag(&mut self, tag_name: &str) { + let tag_lower = tag_name.to_lowercase(); + if tag_lower == "reset" { + return; + } + if let Some(pos) = self + .stack + .iter() + .rposition(|(_, name)| name.as_ref().eq_ignore_ascii_case(&tag_lower)) + { + self.stack.truncate(pos); + } + } + + fn process_open_tag( + &mut self, + tag_name: &'a str, + args: SmallVec<[Cow<'a, str>; 4]>, + self_closing: bool, + ) { + let parent = self.stack.last().map(|s| s.0).unwrap_or(0); + let lower = tag_name.to_lowercase(); + + match lower.as_str() { + "b" | "bold" | "!b" | "!bold" | "i" | "em" | "italic" | "!i" | "!em" | "!italic" + | "u" | "underlined" | "!u" | "!underlined" | "st" | "strikethrough" | "!st" + | "!strikethrough" | "obf" | "obfuscated" | "!obf" | "!obfuscated" => { + let (decoration, value) = if let Some(rest) = lower.strip_prefix('!') { + (rest, false) + } else { + (lower.as_str(), true) + }; + + let f = Format::new(); + let format = match decoration { + "b" | "bold" => f.bold(value), + "i" | "em" | "italic" => f.italic(value), + "u" | "underlined" => f.underlined(value), + "st" | "strikethrough" => f.strikethrough(value), + "obf" | "obfuscated" => f.obfuscated(value), + _ => return, + }; + + self.push_format_tag(parent, format, self_closing, tag_name); + } + "reset" => self.stack.truncate(1), + "shadow" => { + let format = parse_shadow(&args); + self.push_format_tag(parent, format, self_closing, "shadow"); + } + "!shadow" => { + let mut fmt = Format::new(); + fmt.shadow_color = Some(0); + self.push_format_tag(parent, fmt, self_closing, "!shadow"); + } + "color" | "c" | "colour" => { + let color = args.first().and_then(|a| parse_color(a)); + let format = match color { + Some(c) => Format::new().color(c), + None => Format::new(), + }; + self.push_format_tag(parent, format, self_closing, tag_name); + } + "click" => { + let mut args = args; + if let Some(action) = take_first_arg(&mut args) { + let value = join_with_colon(&args); + let click = parse_click(&action, value); + let mut comp = new_component(Content::Text { + text: Cow::Borrowed(""), + }); + comp.interactions.click = click; + self.push_tag_to_stack( + parent, + comp, + Some(Cow::Borrowed("click")), + self_closing, + ); + } + } + "hover" => { + let hover = parse_hover(args); + let mut comp = new_component(Content::Text { + text: Cow::Borrowed(""), + }); + comp.interactions.hover = hover; + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed("hover")), self_closing); + } + "insert" => { + let mut args = args; + if let Some(text) = take_first_arg(&mut args) { + let mut comp = new_component(Content::Text { + text: Cow::Borrowed(""), + }); + comp.interactions.insertion = Some(text); + self.push_tag_to_stack( + parent, + comp, + Some(Cow::Borrowed("insert")), + self_closing, + ); + } + } + "font" => { + let font = join_with_colon(&args); + self.push_format_tag(parent, Format::new().font(font), self_closing, "font"); + } + "key" => { + let keybind = join_with_colon(&args); + let comp = new_component(Content::Keybind { keybind }); + self.add_child_node(parent, comp); + } + "lang" | "tr" | "translate" => { + self.handle_translate_tag(args, parent, None); + } + "lang_or" | "tr_or" | "translate_or" => { + self.handle_translate_tag(args, parent, Some(true)); + } + "newline" | "br" => { + self.add_child_node(parent, RawTextComponent::plain("\n")); + } + "selector" | "sel" => { + self.handle_selector_tag(args, parent); + } + "score" => { + let mut args = args; + if let (Some(name), Some(objective)) = + (take_first_arg(&mut args), take_first_arg(&mut args)) + { + let resolvable = Resolvable::Scoreboard { + selector: name, + objective, + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); + } + } + "nbt" | "data" => { + self.handle_nbt_tag(args, parent); + } + "sprite" => { + if args.is_empty() { + return; + } + let mut args = args; + let atlas = if args.len() > 1 { + take_first_arg(&mut args) + } else { + None + }; + let sprite = take_first_arg(&mut args).unwrap(); + let comp = new_component(Content::Object(Object::Atlas { atlas, sprite })); + self.add_child_node(parent, comp); + } + "head" => { + self.handle_head_tag(args, parent); + } + #[cfg(feature = "custom")] + "rainbow" => { + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed("rainbow"), + payload: Payload::Empty, + })); + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed("rainbow")), self_closing); + } + #[cfg(feature = "custom")] + "gradient" => { + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed("gradient"), + payload: Payload::Empty, + })); + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed("gradient")), self_closing); + } + #[cfg(feature = "custom")] + "transition" => { + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed("transition"), + payload: Payload::Empty, + })); + self.push_tag_to_stack( + parent, + comp, + Some(Cow::Borrowed("transition")), + self_closing, + ); + } + #[cfg(feature = "custom")] + "pride" => { + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed("pride"), + payload: Payload::Empty, + })); + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed("pride")), self_closing); + } + _ => { + let color = parse_color(&lower).or_else(|| { + if lower.starts_with('#') { + Color::from_hex(&lower) + } else { + None + } + }); + + if let Some(color) = color { + self.push_format_tag( + parent, + Format::new().color(color), + self_closing, + tag_name, + ); + } + } + } + } + + fn handle_translate_tag( + &mut self, + args: SmallVec<[Cow<'a, str>; 4]>, + parent: usize, + has_fallback: Option, + ) { + let mut args = args; + let key = take_first_arg(&mut args); + let fallback = if has_fallback.is_some() { + take_first_arg(&mut args) + } else { + None + }; + + if let Some(key) = key { + let t_args: Vec> = args + .into_iter() + .map(|a| match a { + Cow::Borrowed(s) => parse_raw(s), + Cow::Owned(ref s) => parse(s), + }) + .collect(); + let msg = TranslatedMessage { + key, + fallback, + args: if t_args.is_empty() { + None + } else { + Some(t_args.into_boxed_slice()) + }, + }; + let comp = new_component(Content::Translate(msg)); + self.add_child_node(parent, comp); + } + } + + fn handle_selector_tag(&mut self, args: SmallVec<[Cow<'a, str>; 4]>, parent: usize) { + let mut args = args; + if let Some(sel) = take_first_arg(&mut args) { + let separator = if let Some(sep) = take_first_arg(&mut args) { + match sep { + Cow::Borrowed(s) => Box::new(parse_raw(s)), + Cow::Owned(ref s) => Box::new(parse(s)), + } + } else { + Resolvable::entity_separator() + }; + let resolvable = Resolvable::Entity { + selector: sel, + separator, + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); + } + } + + fn handle_nbt_tag(&mut self, args: SmallVec<[Cow<'a, str>; 4]>, parent: usize) { + let mut args = args; + if args.len() >= 3 { + let source_type = take_first_arg(&mut args).unwrap_or(Cow::Borrowed("")); + let id = take_first_arg(&mut args); + let path = take_first_arg(&mut args); + if let (Some(id), Some(path)) = (id, path) { + let sep = take_first_arg(&mut args); + let separator = if let Some(s) = sep { + match s { + Cow::Borrowed(s) => Box::new(parse_raw(s)), + Cow::Owned(ref s) => Box::new(parse(s)), + } + } else { + Resolvable::nbt_separator() + }; + let interpret = args.first().is_some_and(|v| v.as_ref() == "interpret"); + let source = match source_type.as_ref() { + "entity" => NbtSource::Entity(id), + "block" => NbtSource::Block(id), + "storage" => NbtSource::Storage(id), + _ => return, + }; + let resolvable = Resolvable::NBT { + path, + interpret: if interpret { Some(true) } else { None }, + separator, + source, + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); + } + } + } + + fn handle_head_tag(&mut self, args: SmallVec<[Cow<'a, str>; 4]>, parent: usize) { + let mut args = args; + if let Some(head_str) = take_first_arg(&mut args) { + let outer_layer = args.first().is_none_or(|v| v.as_ref() != "false"); + let player = if let Ok(uuid) = uuid::Uuid::parse_str(head_str.as_ref()) { + let (high, low) = uuid.as_u64_pair(); + let id = [ + (high >> 32) as i32, + high as i32, + (low >> 32) as i32, + low as i32, + ]; + ObjectPlayer::id(id) + } else if head_str.as_ref().contains('/') || head_str.as_ref().contains(':') { + ObjectPlayer::texture(head_str) + } else { + ObjectPlayer::name(head_str) + }; + let comp = new_component(Content::Object(Object::Player { + player, + hat: outer_layer, + })); + self.add_child_node(parent, comp); + } + } + + fn finish(mut self) -> RawTextComponent<'a> { + self.stack.truncate(1); + self.build_node(0) + } + + fn build_node(&mut self, idx: usize) -> RawTextComponent<'a> { + let child_indices = std::mem::take(&mut self.children[idx]); + let mut node = std::mem::take(&mut self.nodes[idx]); + node.children = child_indices + .into_iter() + .map(|cidx| self.build_node(cidx)) + .collect(); + node + } +} + +fn take_first_arg<'a>(args: &mut SmallVec<[Cow<'a, str>; 4]>) -> Option> { + if args.is_empty() { + return None; + } + Some(args.remove(0)) +} + +fn unescape_text<'a>(s: &'a str) -> Cow<'a, str> { + if !s.contains('\\') { + return Cow::Borrowed(s); + } + let bytes = s.as_bytes(); + let mut result = String::with_capacity(s.len()); + let mut start = 0; + while let Some(rel_pos) = memchr::memchr(b'\\', &bytes[start..]) { + let pos = start + rel_pos; + result.push_str(&s[start..pos]); + start = pos + 1; + if start < bytes.len() { + let next_byte = bytes[start]; + match next_byte { + b'<' | b'\\' => { + result.push(next_byte as char); + start += 1; + } + other => { + result.push('\\'); + result.push(other as char); + start += 1; + } + } + } else { + result.push('\\'); + break; + } + } + if start < s.len() { + result.push_str(&s[start..]); + } + Cow::Owned(result) +} + +fn split_args<'a>(input: &'a str, pos: &mut usize, max: usize) -> SmallVec<[Cow<'a, str>; 4]> { + let mut args = SmallVec::new(); + let bytes = input.as_bytes(); + while *pos < max { + let start = *pos; + if bytes[*pos] == b'"' || bytes[*pos] == b'\'' { + let quote = bytes[*pos]; + *pos += 1; + let content_start = *pos; + let mut escaped = String::new(); + let mut has_escape = false; + loop { + let rem = &bytes[*pos..max]; + match memchr::memchr2(quote, b'\\', rem) { + None => { + if has_escape { + escaped.push_str(&input[content_start..max]); + args.push(Cow::Owned(escaped)); + } else { + args.push(Cow::Borrowed(&input[content_start..max])); + } + *pos = max; + break; + } + Some(offset) => { + let found = rem[offset]; + let abs_pos = *pos + offset; + if found == quote { + if has_escape { + escaped.push_str(&input[*pos..abs_pos]); + args.push(Cow::Owned(escaped)); + } else { + args.push(Cow::Borrowed(&input[*pos..abs_pos])); + } + *pos = abs_pos + 1; + break; + } else { + has_escape = true; + if escaped.is_empty() { + escaped.push_str(&input[content_start..abs_pos]); + } else { + escaped.push_str(&input[*pos..abs_pos]); + } + *pos = abs_pos + 1; + if *pos < max { + let next_byte = bytes[*pos]; + match next_byte { + b'\\' => escaped.push('\\'), + b'"' if quote == b'"' => escaped.push('"'), + b'\'' if quote == b'\'' => escaped.push('\''), + c => { + escaped.push('\\'); + escaped.push(c as char); + } + } + *pos += 1; + } else { + escaped.push('\\'); + args.push(Cow::Owned(escaped)); + *pos = max; + break; + } + } + } + } + } + if *pos < max && bytes[*pos] == b':' { + *pos += 1; + continue; + } else { + break; + } + } else { + if let Some(offset) = memchr::memchr2(b':', b'>', &bytes[*pos..max]) { + let idx = *pos + offset; + let found = bytes[idx]; + if found == b'>' { + if start < idx { + args.push(Cow::Borrowed(&input[start..idx])); + } + *pos = idx; + break; + } else { + args.push(Cow::Borrowed(&input[start..idx])); + *pos = idx + 1; + continue; + } + } else { + args.push(Cow::Borrowed(&input[start..max])); + *pos = max; + break; + } + } + } + args +} + +fn parse_color(s: &str) -> Option { + Color::from_hex(s).or_else(|| { + let color = match s { + "black" => Color::Black, + "dark_blue" => Color::DarkBlue, + "dark_green" => Color::DarkGreen, + "dark_aqua" => Color::DarkAqua, + "dark_red" => Color::DarkRed, + "dark_purple" => Color::DarkPurple, + "gold" => Color::Gold, + "gray" | "grey" => Color::Gray, + "dark_gray" | "dark_grey" => Color::DarkGray, + "blue" => Color::Blue, + "green" => Color::Green, + "aqua" => Color::Aqua, + "red" => Color::Red, + "light_purple" => Color::LightPurple, + "yellow" => Color::Yellow, + "white" => Color::White, + _ => return None, + }; + Some(color) + }) +} + +fn color_to_rgb(color: &Color) -> (u8, u8, u8) { + match color { + Color::Black => (0, 0, 0), + Color::DarkBlue => (0, 0, 170), + Color::DarkGreen => (0, 170, 0), + Color::DarkAqua => (0, 170, 170), + Color::DarkRed => (170, 0, 0), + Color::DarkPurple => (170, 0, 170), + Color::Gold => (255, 170, 0), + Color::Gray => (170, 170, 170), + Color::DarkGray => (85, 85, 85), + Color::Blue => (85, 85, 255), + Color::Green => (85, 255, 85), + Color::Aqua => (85, 255, 255), + Color::Red => (255, 85, 85), + Color::LightPurple => (255, 85, 255), + Color::Yellow => (255, 255, 85), + Color::White => (255, 255, 255), + Color::Rgb(r, g, b) => (*r, *g, *b), + } +} + +fn parse_click<'a>(action: &str, value: Cow<'a, str>) -> Option> { + let event = match action { + "open_url" => ClickEvent::OpenUrl { url: value }, + "run_command" => ClickEvent::RunCommand { command: value }, + "suggest_command" => ClickEvent::SuggestCommand { command: value }, + "change_page" => { + let page = value.as_ref().parse::().ok()?; + ClickEvent::ChangePage { page } + } + "copy_to_clipboard" => ClickEvent::CopyToClipboard { value }, + "show_dialog" => ClickEvent::ShowDialog { dialog: value }, + #[cfg(feature = "custom")] + "custom" => ClickEvent::Custom(CustomData { + id: value, + payload: Payload::Empty, + }), + _ => return None, + }; + Some(event) +} + +fn parse_hover<'a>(args: SmallVec<[Cow<'a, str>; 4]>) -> Option> { + let first = args.first()?.as_ref(); + match first { + "show_text" => { + let cow = args.get(1)?.clone(); // Cow копирует ссылку или владение + let text = match cow { + Cow::Borrowed(s) => parse_raw(s), + Cow::Owned(ref s) => parse(s), + }; + Some(HoverEvent::ShowText { + value: Box::new(text), + }) + } + "show_item" => { + let id = args.get(1)?.clone(); + let count = args.get(2).and_then(|s| s.parse::().ok()); + let components = args.get(3).cloned(); + Some(HoverEvent::ShowItem { + id, + count, + components, + }) + } + "show_entity" => { + let id = args.get(1)?.clone(); + let uuid = uuid::Uuid::parse_str(args.get(2)?.as_ref()).ok()?; + let name_cow = args.get(3).cloned(); + let name = name_cow.map(|cow| { + Box::new(match cow { + Cow::Borrowed(s) => parse_raw(s), + Cow::Owned(ref s) => parse(s), + }) + }); + Some(HoverEvent::ShowEntity { name, id, uuid }) + } + _ => None, + } +} + +fn parse_shadow<'a>(args: &[Cow<'a, str>]) -> Format<'a> { + let mut format = Format::new(); + if args.is_empty() { + return format; + } + let color_arg = &args[0]; + let alpha_from_args = |idx: usize| { + args.get(idx) + .and_then(|a| a.parse::().ok()) + .map(|f| (f * 255.0).round() as u8) + }; + + let shadow = if let Some(hex) = color_arg.as_ref().strip_prefix('#') { + if hex.len() == 8 { + if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + u8::from_str_radix(&hex[6..8], 16), + ) { + Some(Format::parse_shadow_color(a, r, g, b)) + } else { + None + } + } else if hex.len() == 6 { + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&hex[0..2], 16), + u8::from_str_radix(&hex[2..4], 16), + u8::from_str_radix(&hex[4..6], 16), + ) { + let a = alpha_from_args(1).unwrap_or(64); + Some(Format::parse_shadow_color(a, r, g, b)) + } else { + None + } + } else { + None + } + } else if let Some(color) = parse_color(color_arg.as_ref()) { + let (r, g, b) = color_to_rgb(&color); + let a = alpha_from_args(1).unwrap_or(64); + Some(Format::parse_shadow_color(a, r, g, b)) + } else { + None + }; + + if let Some(s) = shadow { + format.shadow_color = Some(s); + } + format +} diff --git a/src/minimessage_tests.rs b/src/minimessage_tests.rs new file mode 100644 index 0000000..65fbb32 --- /dev/null +++ b/src/minimessage_tests.rs @@ -0,0 +1,793 @@ +use std::borrow::Cow; + +use crate::{ + RawTextComponent, + content::{Content, NbtSource, Object, Resolvable}, + format::{Color, Format}, + interactivity::{ClickEvent, HoverEvent}, + minimessage::*, +}; + +fn first_child<'a>(comp: &'a RawTextComponent<'a>) -> &'a RawTextComponent<'a> { + comp.children.first().expect("expected at least one child") +} + +fn children<'a>(comp: &'a RawTextComponent<'a>) -> &'a [RawTextComponent<'a>] { + &comp.children +} + +#[test] +fn plain_text() { + let root = parse("Hello"); + let child = first_child(&root); + assert_eq!( + child.content, + Content::Text { + text: Cow::Borrowed("Hello") + } + ); + assert!(child.format.color.is_none()); + assert!(child.format.bold.is_none()); + assert!(child.interactions.click.is_none()); +} + +#[test] +fn color_named() { + let root = parse("Test"); + let child = first_child(&root); + assert_eq!(child.format.color, Some(Color::Red)); + assert_eq!(child.children.len(), 1); + assert_eq!( + child.children[0].content, + Content::Text { + text: Cow::Borrowed("Test") + } + ); +} + +#[test] +fn color_hex() { + let root = parse("<#00ff00>Green"); + let child = first_child(&root); + assert_eq!(child.format.color, Some(Color::Rgb(0, 255, 0))); +} + +#[test] +fn color_nested() { + let root = parse("Hello World!"); + let top_child = first_child(&root); + assert_eq!( + top_child.content, + Content::Text { + text: Cow::Borrowed("") + } + ); + assert_eq!(top_child.format.color, Some(Color::Yellow)); + assert_eq!(top_child.children.len(), 3); + + let hello = &top_child.children[0]; + assert_eq!( + hello.content, + Content::Text { + text: Cow::Borrowed("Hello ") + } + ); + + let blue_wrapper = &top_child.children[1]; + assert_eq!(blue_wrapper.format.color, Some(Color::Blue)); + assert_eq!(blue_wrapper.children.len(), 1); + let world = &blue_wrapper.children[0]; + assert_eq!( + world.content, + Content::Text { + text: Cow::Borrowed("World") + } + ); + + let excl = &top_child.children[2]; + assert_eq!( + excl.content, + Content::Text { + text: Cow::Borrowed("!") + } + ); +} + +#[test] +fn bold() { + let root = parse("Bold text"); + let child = first_child(&root); + assert_eq!(child.format.bold, Some(true)); +} + +#[test] +fn not_bold() { + let root = parse("Not bold"); + let child = first_child(&root); + assert_eq!(child.format.bold, Some(false)); +} + +#[test] +fn italic_aliases() { + for tag in &["i", "em", "italic"] { + let root = parse(&format!("<{}>Italic", tag, tag)); + let child = first_child(&root); + assert_eq!(child.format.italic, Some(true), "failed for tag {}", tag); + } +} + +#[test] +fn underlined() { + let root = parse("Under"); + let child = first_child(&root); + assert_eq!(child.format.underlined, Some(true)); +} + +#[test] +fn strikethrough() { + let root = parse("Strike"); + let child = first_child(&root); + assert_eq!(child.format.strikethrough, Some(true)); +} + +#[test] +fn obfuscated() { + let root = parse("Obfuscated"); + let child = first_child(&root); + assert_eq!(child.format.obfuscated, Some(true)); +} + +#[test] +fn negation_underlined() { + let root = parse("Not underlined"); + let child = first_child(&root); + assert_eq!(child.format.underlined, Some(false)); +} + +#[test] +fn reset_clears_style() { + let root = parse("Hello world!"); + let kids = children(&root); + assert_eq!(kids.len(), 2); + + let yellow = &kids[0]; + assert_eq!(yellow.format.color, Some(Color::Yellow)); + assert!(yellow.format.bold.is_none()); + assert_eq!(yellow.children.len(), 1); + + let bold = &yellow.children[0]; + assert_eq!(bold.format.bold, Some(true)); + assert_eq!(bold.children.len(), 1); + assert_eq!( + bold.children[0].content, + Content::Text { + text: Cow::Borrowed("Hello ") + } + ); + + let world = &kids[1]; + assert!(world.format.color.is_none()); + assert!(world.format.bold.is_none()); + assert_eq!( + world.content, + Content::Text { + text: Cow::Borrowed("world!") + } + ); +} + +#[test] +fn shadow_named() { + let root = parse("Shadow"); + let child = first_child(&root); + let expected = Format::parse_shadow_color(64, 255, 85, 85); + assert_eq!(child.format.shadow_color, Some(expected)); +} + +#[test] +fn shadow_alpha() { + let root = parse("Test"); + let child = first_child(&root); + let expected = Format::parse_shadow_color(128, 85, 255, 255); + assert_eq!(child.format.shadow_color, Some(expected)); +} + +#[test] +fn shadow_hex() { + let root = parse("Red shadow"); + let child = first_child(&root); + let expected = Format::parse_shadow_color(64, 255, 0, 0); + assert_eq!(child.format.shadow_color, Some(expected)); +} + +#[test] +fn shadow_hex_with_alpha() { + let root = parse("Red shadow alpha"); + let child = first_child(&root); + let expected = Format::parse_shadow_color(0x80, 255, 0, 0); + assert_eq!(child.format.shadow_color, Some(expected)); +} + +#[test] +fn shadow_disable() { + let root = parse("No shadow"); + let child = first_child(&root); + assert_eq!(child.format.shadow_color, Some(0)); +} + +#[test] +fn verbose_color() { + for tag in &["color", "c", "colour"] { + let root = parse(&format!("<{}:blue>Blue", tag, tag)); + let child = first_child(&root); + assert_eq!(child.format.color, Some(Color::Blue), "tag {}", tag); + } +} + +#[test] +fn click_run_command() { + let root = parse("Click"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::RunCommand { + command: Cow::Owned("/seed".into()) + }) + ); +} + +#[test] +fn click_open_url() { + let root = parse("Link"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::OpenUrl { + url: Cow::Owned("https://example.com".into()) + }) + ); +} + +#[test] +fn click_suggest_command() { + let root = parse("Suggest"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::SuggestCommand { + command: Cow::Owned("/help".into()) + }) + ); +} + +#[test] +fn click_change_page() { + let root = parse("Page 3"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::ChangePage { page: 3 }) + ); +} + +#[test] +fn click_copy_to_clipboard() { + let root = parse("Copy"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::CopyToClipboard { + value: Cow::Owned("secret".into()) + }) + ); +} + +#[test] +fn click_show_dialog() { + let root = parse("Dialog"); + let child = first_child(&root); + assert_eq!( + child.interactions.click, + Some(ClickEvent::ShowDialog { + dialog: Cow::Owned("dialog_id".into()) + }) + ); +} + +#[cfg(feature = "custom")] +#[test] +fn click_custom() { + let root = parse("Custom"); + let child = first_child(&root); + match &child.interactions.click { + Some(ClickEvent::Custom(data)) => { + assert_eq!(data.id, "my_action"); + } + _ => panic!("expected custom click event"), + } +} + +#[test] +fn hover_show_text() { + let root = parse("test'>Hover"); + let child = first_child(&root); + match &child.interactions.hover { + Some(HoverEvent::ShowText { value }) => { + let inner = value; + let inner_child = inner.children.first().unwrap(); + assert_eq!(inner_child.format.color, Some(Color::Red)); + assert_eq!( + inner_child.children[0].content, + Content::Text { + text: Cow::Borrowed("test") + } + ); + } + _ => panic!("expected show_text hover event"), + } +} + +#[test] +fn hover_show_item() { + let root = parse("Item"); + let child = first_child(&root); + assert_eq!( + child.interactions.hover, + Some(HoverEvent::ShowItem { + id: Cow::Owned("stone".into()), + count: Some(3), + components: Some(Cow::Owned("tag".into())), + }) + ); +} + +#[test] +fn hover_show_entity() { + let uuid_str = "1f085b2d-9548-4159-a8c7-f3ccdf0c2054"; + let root = parse(&format!("Entity", uuid_str)); + let child = first_child(&root); + match &child.interactions.hover { + Some(HoverEvent::ShowEntity { id, uuid, name }) => { + assert_eq!(id.as_ref(), "cow"); + assert_eq!(*uuid, uuid::Uuid::parse_str(uuid_str).unwrap()); + let name_comp = name.as_ref().unwrap(); + let name_text = name_comp.children.first().unwrap(); + assert_eq!( + name_text.content, + Content::Text { + text: Cow::Borrowed("Name") + } + ); + } + _ => panic!("expected show_entity hover event"), + } +} + +#[test] +fn insertion() { + let root = parse("Insert"); + let child = first_child(&root); + assert_eq!( + child.interactions.insertion, + Some(Cow::Owned("test".into())) + ); +} + +#[test] +fn font() { + let root = parse("Uniform text"); + let child = first_child(&root); + assert_eq!(child.format.font, Some(Cow::Owned("uniform".into()))); +} + +#[test] +fn font_with_namespace() { + let root = parse("Custom"); + let child = first_child(&root); + assert_eq!( + child.format.font, + Some(Cow::Owned("myfont:custom_font".into())) + ); +} + +#[test] +fn keybind() { + let root = parse(""); + let child = first_child(&root); + assert_eq!( + child.content, + Content::Keybind { + keybind: Cow::Owned("key.jump".into()) + } + ); +} + +#[test] +fn translate() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Translate(msg) => { + assert_eq!(msg.key, "block.minecraft.diamond_block"); + assert!(msg.fallback.is_none()); + assert!(msg.args.is_none()); + } + _ => panic!("expected translation"), + } +} + +#[test] +fn translate_with_args() { + let root = parse("1':'Stone'>"); + let child = first_child(&root); + match &child.content { + Content::Translate(msg) => { + assert_eq!(msg.key, "commands.drop.success.single"); + let args = msg.args.as_ref().unwrap(); + assert_eq!(args.len(), 2); + let arg1 = &args[0]; + let red_child = arg1.children.first().unwrap(); + assert_eq!(red_child.format.color, Some(Color::Red)); + assert_eq!( + red_child.children[0].content, + Content::Text { + text: Cow::Borrowed("1") + } + ); + let arg2 = &args[1]; + let blue_child = arg2.children.first().unwrap(); + assert_eq!(blue_child.format.color, Some(Color::Blue)); + assert_eq!( + blue_child.children[0].content, + Content::Text { + text: Cow::Borrowed("Stone") + } + ); + } + _ => panic!("expected translation"), + } +} + +#[test] +fn translate_with_fallback() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Translate(msg) => { + assert_eq!(msg.key, "my.key"); + assert_eq!(msg.fallback, Some(Cow::Owned("Fallback".into()))); + assert!(msg.args.is_none()); + } + _ => panic!("expected translation with fallback"), + } +} + +#[test] +fn newline() { + let root = parse("Line1Line2"); + let kids = children(&root); + assert_eq!(kids.len(), 1); + assert_eq!( + kids[0].content, + Content::Text { + text: Cow::Borrowed("Line1\nLine2") + } + ); +} + +#[test] +fn selector() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::Entity { + selector, + separator: _, + }) => { + assert_eq!(selector, "@a"); + } + _ => panic!("expected entity selector"), + } +} + +#[test] +fn selector_with_separator() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::Entity { + selector, + separator, + }) => { + assert_eq!(selector, "@a"); + let sep_text = separator.children.first().unwrap(); + assert_eq!( + sep_text.content, + Content::Text { + text: Cow::Borrowed(", ") + } + ); + } + _ => panic!("expected entity selector with separator"), + } +} + +#[test] +fn score() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::Scoreboard { + selector, + objective, + }) => { + assert_eq!(selector, "player"); + assert_eq!(objective, "deaths"); + } + _ => panic!("expected scoreboard"), + } +} + +#[test] +fn nbt_entity() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::NBT { + path, + source, + interpret, + separator: _, + }) => { + assert_eq!(path, "Health"); + assert_eq!(*source, NbtSource::Entity(Cow::Owned("@s".into()))); + assert!(interpret.is_none()); + } + _ => panic!("expected nbt"), + } +} + +#[test] +fn nbt_with_interpret() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::NBT { + source, interpret, .. + }) => { + assert!(*interpret == Some(true)); + assert_eq!(*source, NbtSource::Block(Cow::Owned("12 34 56".into()))); + } + _ => panic!("expected nbt with interpret"), + } +} + +#[test] +fn nbt_with_separator() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Resolvable(Resolvable::NBT { + separator, + source, + interpret, + .. + }) => { + assert_eq!(*source, NbtSource::Storage(Cow::Owned("foo".into()))); + assert!(*interpret == Some(true)); + let sep_text = separator.children.first().unwrap(); + assert_eq!( + sep_text.content, + Content::Text { + text: Cow::Borrowed(", ") + } + ); + } + _ => panic!("expected nbt with separator"), + } +} + +#[test] +fn sprite_full() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Object(Object::Atlas { atlas, sprite }) => { + assert_eq!(atlas.as_deref(), Some("blocks")); + assert_eq!(sprite, "item/diamond_sword"); + } + _ => panic!("expected sprite"), + } +} + +#[test] +fn sprite_only() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Object(Object::Atlas { atlas, sprite }) => { + assert!(atlas.is_none()); + assert_eq!(sprite, "item/emerald"); + } + _ => panic!("expected sprite"), + } +} + +#[test] +fn head_by_name() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Object(Object::Player { player, hat }) => { + assert!(hat); + assert_eq!(player.name, Some("Strokkur24".into())); + } + _ => panic!("expected player head"), + } +} + +#[test] +fn head_no_outer_layer() { + let root = parse(""); + let child = first_child(&root); + match &child.content { + Content::Object(Object::Player { player: _, hat }) => assert!(!hat), + _ => panic!("expected head"), + } +} + +#[test] +fn head_by_uuid() { + let uuid_str = "1f085b2d-9548-4159-a8c7-f3ccdf0c2054"; + let root = parse(&format!("", uuid_str)); + let child = first_child(&root); + assert!(matches!( + child.content, + Content::Object(Object::Player { .. }) + )); +} + +#[cfg(feature = "custom")] +#[test] +fn rainbow() { + let root = parse("hello"); + let child = first_child(&root); + match &child.content { + Content::Custom(data) => assert_eq!(data.id, "rainbow"), + _ => panic!("expected rainbow custom element"), + } +} + +#[cfg(feature = "custom")] +#[test] +fn gradient() { + let root = parse("hello"); + let child = first_child(&root); + match &child.content { + Content::Custom(data) => assert_eq!(data.id, "gradient"), + _ => panic!("expected gradient"), + } +} + +#[cfg(feature = "custom")] +#[test] +fn transition() { + let root = parse("hello"); + let child = first_child(&root); + match &child.content { + Content::Custom(data) => assert_eq!(data.id, "transition"), + _ => panic!("expected transition"), + } +} + +#[cfg(feature = "custom")] +#[test] +fn pride() { + let root = parse("hello"); + let child = first_child(&root); + match &child.content { + Content::Custom(data) => assert_eq!(data.id, "pride"), + _ => panic!("expected pride"), + } +} + +#[test] +fn self_closing_tag() { + let root = parse("Hello"); + let kids = children(&root); + assert_eq!(kids.len(), 2); + assert_eq!(kids[0].format.color, Some(Color::Yellow)); + assert_eq!( + kids[0].content, + Content::Text { + text: Cow::Borrowed("") + } + ); + assert_eq!( + kids[1].content, + Content::Text { + text: Cow::Borrowed("Hello") + } + ); +} + +#[test] +fn unclosed_tag() { + let root = parse("Hello"); + let child = first_child(&root); + assert_eq!(child.format.color, Some(Color::Yellow)); + assert_eq!( + child.children[0].content, + Content::Text { + text: Cow::Borrowed("Hello") + } + ); +} + +#[test] +fn escape_backslash() { + let root = parse(r"\\test"); + let kids = children(&root); + assert_eq!(kids.len(), 2); + assert_eq!( + kids[0].content, + Content::Text { + text: Cow::Owned("\\".into()) + } + ); + let red_wrapper = &kids[1]; + assert_eq!(red_wrapper.format.color, Some(Color::Red)); + assert_eq!(red_wrapper.children.len(), 1); + assert_eq!( + red_wrapper.children[0].content, + Content::Text { + text: Cow::Borrowed("test") + } + ); +} + +#[test] +fn unknown_tag_ignored() { + let root = parse("test"); + let child = first_child(&root); + assert_eq!( + child.content, + Content::Text { + text: Cow::Owned("test".into()) + } + ); +} + +#[test] +fn mixed_formatting() { + let root = parse("Text"); + let bold = first_child(&root); + assert_eq!(bold.format.bold, Some(true)); + let italic = &bold.children[0]; + assert_eq!(italic.format.italic, Some(true)); + let text = &italic.children[0]; + assert_eq!( + text.content, + Content::Text { + text: Cow::Borrowed("Text") + } + ); +} + +#[test] +fn quoted_args_with_escaped_quote() { + let root = parse(r"Hover"); + let child = first_child(&root); + match &child.interactions.hover { + Some(HoverEvent::ShowText { value }) => { + let inner_child = value.children.first().unwrap(); + assert_eq!( + inner_child.content, + Content::Text { + text: Cow::Owned("It's a test".into()) + } + ); + } + _ => panic!("expected hover"), + } +}