From 0b855785aedd5cce33bb712b09353e792cc523ab Mon Sep 17 00:00:00 2001 From: suprohub Date: Sun, 3 May 2026 21:41:39 +0300 Subject: [PATCH 01/15] AI Draft --- Cargo.lock | 116 +++-- Cargo.toml | 11 +- src/lib.rs | 1 + src/minimessage.rs | 1132 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1224 insertions(+), 36 deletions(-) create mode 100644 src/minimessage.rs diff --git a/Cargo.lock b/Cargo.lock index 104039f..bc9b1fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bumpalo" @@ -49,9 +49,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "cc" -version = "1.2.59" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -148,6 +148,30 @@ 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 +197,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -215,12 +239,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[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.0", "serde", "serde_core", ] @@ -233,10 +257,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -249,9 +275,9 @@ 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" @@ -290,6 +316,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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" @@ -309,6 +341,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.45" @@ -326,9 +367,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 +378,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" @@ -464,6 +505,12 @@ 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 = "supports-hyperlinks" version = "3.2.0" @@ -489,6 +536,7 @@ dependencies = [ "colored", "heck", "proc-macro2", + "quick-xml", "quote", "rand", "rustc-hash", @@ -533,9 +581,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom", "js-sys", @@ -545,11 +593,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 +606,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.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" dependencies = [ "cfg-if", "once_cell", @@ -576,9 +624,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -586,9 +634,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" dependencies = [ "bumpalo", "proc-macro2", @@ -599,9 +647,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.120" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" dependencies = [ "unicode-ident", ] @@ -717,6 +765,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..2948510 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,17 +36,18 @@ 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" +quick-xml = "0.39" [dev-dependencies] chrono = "0.4" -serde_json = "1.0.149" +serde_json = "1.0" diff --git a/src/lib.rs b/src/lib.rs index 1ea0efc..9f236a5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod custom; pub mod fmt; pub mod format; pub mod interactivity; +pub mod minimessage; #[cfg(feature = "nbt")] pub mod nbt; pub mod parse; diff --git a/src/minimessage.rs b/src/minimessage.rs new file mode 100644 index 0000000..83c8b33 --- /dev/null +++ b/src/minimessage.rs @@ -0,0 +1,1132 @@ +// src/minimessage.rs + +use crate::{ + content::{Content, Object, ObjectPlayer, Resolvable, NbtSource}, + format::{Color, Format}, + interactivity::{ClickEvent, HoverEvent, Interactivity}, + resolving::NoResolutor, + TextComponent, +}; +use quick_xml::escape::unescape; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::Reader; +use quick_xml::encoding::EncodingError; +use quick_xml::escape::EscapeError; +use std::borrow::Cow; +use std::collections::HashMap; +use std::str; +use uuid::Uuid; + +/// Error type for MiniMessage parsing. +#[derive(Debug)] +pub enum MiniMessageError { + Io(quick_xml::Error), + InvalidTag(String), + MissingArgument(String), + InvalidContent(String), +} + +impl std::fmt::Display for MiniMessageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MiniMessageError::Io(e) => write!(f, "IO error: {}", e), + MiniMessageError::InvalidTag(s) => write!(f, "Invalid tag: {}", s), + MiniMessageError::MissingArgument(s) => write!(f, "Missing argument in tag: {}", s), + MiniMessageError::InvalidContent(s) => write!(f, "Invalid content: {}", s), + } + } +} + +impl std::error::Error for MiniMessageError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + MiniMessageError::Io(e) => Some(e), + _ => None, + } + } +} + +impl From for MiniMessageError { + fn from(e: quick_xml::Error) -> Self { + MiniMessageError::Io(e) + } +} + +impl From for MiniMessageError { + fn from(e: EncodingError) -> Self { + MiniMessageError::InvalidContent(format!("Encoding error: {}", e)) + } +} + +impl From for MiniMessageError { + fn from(e: EscapeError) -> Self { + MiniMessageError::InvalidContent(format!("Escape error: {}", e)) + } +} + +/// Parses a MiniMessage string and returns a `TextComponent`. +pub fn from_minimessage(input: &str) -> Result { + let xml = minimessage_to_xml(input)?; + let mut reader = Reader::from_reader(xml.as_bytes()); + reader.config_mut().trim_text(true); + + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf)? { + Event::Start(ref e) if e.name().as_ref() == b"root" => { + let children = parse_children(&mut reader, &mut buf, Some(b"root"))?; + if children.is_empty() { + return Ok(TextComponent::new()); + } + if children.len() == 1 { + return Ok(children.into_iter().next().unwrap()); + } else { + return Ok(TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children, + format: Format::new(), + interactions: Interactivity::new(), + }); + } + } + Event::Eof => break, + _ => {} // skip potential XML declaration + } + } + Err(MiniMessageError::InvalidContent( + "no root element found".into(), + )) +} + +// ---------- MiniMessage to XML ---------- + +fn minimessage_to_xml(input: &str) -> Result { + let mut xml = String::from(""); + xml.push_str(&preprocess_minimessage(input)?); + xml.push_str(""); + Ok(xml) +} + +fn preprocess_minimessage(input: &str) -> Result { + let mut output = String::new(); + let chars: Vec = input.chars().collect(); + let len = chars.len(); + let mut pos = 0; + + while pos < len { + // Escape backslash: just output the next character literally + if chars[pos] == '\\' && pos + 1 < len { + output.push(chars[pos + 1]); + pos += 2; + continue; + } + if chars[pos] == '<' { + pos += 1; // skip '<' + // Closing tag + if pos < len && chars[pos] == '/' { + pos += 1; + let mut name = String::new(); + while pos < len && chars[pos] != '>' { + name.push(chars[pos]); + pos += 1; + } + if pos >= len { + return Err(MiniMessageError::InvalidTag( + "unclosed closing tag".into(), + )); + } + pos += 1; // '>' + output.push_str("'); + continue; + } + + let mut tag_body = String::new(); + let mut self_closing = false; + while pos < len { + if chars[pos] == '/' && pos + 1 < len && chars[pos + 1] == '>' { + self_closing = true; + pos += 2; + break; + } + if chars[pos] == '>' { + pos += 1; + break; + } + tag_body.push(chars[pos]); + pos += 1; + } + + let colon_pos = tag_body.find(':'); + let (raw_name, args_str) = if let Some(p) = colon_pos { + (&tag_body[..p], &tag_body[p + 1..]) + } else { + (tag_body.as_str(), "") + }; + + let mut name = raw_name.trim().to_lowercase(); + let mut args = parse_mm_args(args_str)?; + + // Handle negative decorator etc. + if let Some(real_name) = name.strip_prefix('!') { + // act as self-closing tag that disables the decoration + return Ok(format!("<{}/>", real_name)); + } + + // Detect standalone color (short name or hex code) + if is_standalone_color(&name) { + let original_color = name.clone(); + name = "color".to_string(); + args.insert(0, original_color); // use the original color value + } + + let xml_fragment = mini_tag_to_xml(&name, &args, self_closing)?; + output.push_str(&xml_fragment); + + if !self_closing && is_container_tag(&name) { + let remaining = &input[pos..]; + if let Some((content, end_pos)) = find_closing_tag(remaining, &name) { + let inner_xml = preprocess_minimessage(content)?; + output.push_str(&inner_xml); + output.push_str("'); + pos += end_pos; + } else { + // No closing tag – loose mode: consume rest of string + let inner_xml = preprocess_minimessage(&input[pos..])?; + output.push_str(&inner_xml); + output.push_str("'); + pos = len; + } + } + } else { + let start = pos; + while pos < len && chars[pos] != '<' && chars[pos] != '\\' { + pos += 1; + } + let text: String = chars[start..pos].iter().collect(); + output.push_str(&xml_escape(&text)); + } + } + Ok(output) +} + +fn find_closing_tag<'a>(s: &'a str, name: &str) -> Option<(&'a str, usize)> { + let closing = format!("", name); + if let Some(idx) = s.find(&closing) { + Some((&s[..idx], idx + closing.len())) + } else { + None + } +} + +fn parse_mm_args(args: &str) -> Result, MiniMessageError> { + let mut result = Vec::new(); + let chars: Vec = args.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '\'' || chars[i] == '"' { + let quote = chars[i]; + i += 1; + let start = i; + while i < chars.len() && chars[i] != quote { + i += 1; + } + if i >= chars.len() { + return Err(MiniMessageError::InvalidContent("unclosed quote".into())); + } + let val: String = chars[start..i].iter().collect(); + result.push(val); + i += 1; // skip closing quote + } else { + let start = i; + while i < chars.len() && chars[i] != ':' { + i += 1; + } + let val: String = chars[start..i].iter().collect(); + result.push(val.trim().to_string()); + } + if i < chars.len() && chars[i] == ':' { + i += 1; + } + } + Ok(result) +} + +fn is_standalone_color(name: &str) -> bool { + Color::from_name(name).is_some() || name.starts_with('#') +} + +fn mini_tag_to_xml( + name: &str, + args: &[String], + self_closing: bool, +) -> Result { + let mut attrs = String::new(); + let mut inner = String::new(); + + match name { + "color" | "c" | "colour" => { + let col = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("color".into()))?; + attrs.push_str(&format!("name=\"{}\"", xml_escape(&col))); + } + "shadow" => { + let col = args.first().cloned().unwrap_or_default(); + let alpha = args.get(1).cloned().unwrap_or_else(|| "0.25".into()); + attrs.push_str(&format!( + "color=\"{}\" alpha=\"{}\"", + xml_escape(&col), + alpha + )); + } + "bold" | "b" | "italic" | "em" | "i" | "underlined" | "u" | "strikethrough" | "st" + | "obfuscated" | "obf" => {} + "reset" => return Ok("".into()), + "click" => { + let action = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("click action".into()))?; + let value = args.get(1).cloned().unwrap_or_default(); + attrs.push_str(&format!( + "action=\"{}\" value=\"{}\"", + action, + xml_escape(&value) + )); + } + "hover" => { + let action = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("hover action".into()))?; + match action.as_str() { + "show_text" => { + let val = args.get(1).cloned().unwrap_or_default(); + let sub_xml = preprocess_minimessage(&val)?; + inner.push_str(&format!("{}", sub_xml)); + } + "show_item" => { + let item = args.get(1).cloned().unwrap_or_default(); + let count = args.get(2).cloned(); + let tag = args.get(3).cloned(); + attrs.push_str(&format!("item=\"{}\"", xml_escape(&item))); + if let Some(c) = count { + attrs.push_str(&format!(" count=\"{}\"", c)); + } + if let Some(t) = tag { + attrs.push_str(&format!(" tag=\"{}\"", xml_escape(&t))); + } + } + "show_entity" => { + let etype = args.get(1).cloned().unwrap_or_default(); + let uuid = args.get(2).cloned().unwrap_or_default(); + let name_mm = args.get(3).cloned(); + attrs.push_str(&format!( + "type=\"{}\" uuid=\"{}\"", + xml_escape(&etype), + uuid + )); + if let Some(n) = name_mm { + let n_xml = preprocess_minimessage(&n)?; + inner.push_str(&format!("{}", n_xml)); + } + } + _ => { + return Err(MiniMessageError::InvalidTag(format!( + "hover action: {}", + action + ))) + } + } + } + "keybind" | "key" => { + let key = args.first().cloned().unwrap_or_default(); + attrs.push_str(&format!("key=\"{}\"", xml_escape(&key))); + } + "lang" | "tr" | "translate" => { + let key = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("key".into()))?; + attrs.push_str(&format!("key=\"{}\"", xml_escape(&key))); + for arg_mm in &args[1..] { + let arg_xml = preprocess_minimessage(arg_mm)?; + inner.push_str(&format!("{}", arg_xml)); + } + } + "lang_or" | "tr_or" | "translate_or" => { + let key = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("key".into()))?; + let fallback = args + .get(1) + .cloned() + .ok_or(MiniMessageError::MissingArgument("fallback".into()))?; + attrs.push_str(&format!( + "key=\"{}\" fallback=\"{}\"", + xml_escape(&key), + xml_escape(&fallback) + )); + for arg_mm in &args[2..] { + let arg_xml = preprocess_minimessage(arg_mm)?; + inner.push_str(&format!("{}", arg_xml)); + } + } + "insertion" | "insert" => { + let val = args.first().cloned().unwrap_or_default(); + attrs.push_str(&format!("value=\"{}\"", xml_escape(&val))); + } + "selector" | "sel" => { + let sel = args.first().cloned().unwrap_or_default(); + attrs.push_str(&format!("selector=\"{}\"", xml_escape(&sel))); + if let Some(sep_mm) = args.get(1) { + let sep_xml = preprocess_minimessage(sep_mm)?; + inner.push_str(&format!("{}", sep_xml)); + } + } + "score" => { + let name = args.first().cloned().unwrap_or_default(); + let obj = args.get(1).cloned().unwrap_or_default(); + attrs.push_str(&format!( + "name=\"{}\" objective=\"{}\"", + xml_escape(&name), + xml_escape(&obj) + )); + } + "nbt" | "data" => { + let source = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("nbt source".into()))?; + let id = args.get(1).cloned().unwrap_or_default(); + let path = args.get(2).cloned().unwrap_or_default(); + attrs.push_str(&format!( + "source=\"{}\" id=\"{}\" path=\"{}\"", + source, id, path + )); + let mut idx = 3; + if let Some(sep_mm) = args.get(3) && sep_mm != "interpret" { + let sep_xml = preprocess_minimessage(sep_mm)?; + inner.push_str(&format!("{}", sep_xml)); + idx += 1; + } + if args.get(idx).map(|s| s.as_str()) == Some("interpret") { + attrs.push_str(" interpret=\"true\""); + } + } + "sprite" => { + let atlas = args + .first() + .cloned() + .unwrap_or_else(|| "minecraft:blocks".into()); + let sprite = args + .get(1) + .cloned() + .ok_or(MiniMessageError::MissingArgument("sprite".into()))?; + attrs.push_str(&format!( + "atlas=\"{}\" sprite=\"{}\"", + xml_escape(&atlas), + xml_escape(&sprite) + )); + } + "head" => { + let id = args + .first() + .cloned() + .ok_or(MiniMessageError::MissingArgument("head id".into()))?; + let hat = args.get(1).map(|s| s != "false").unwrap_or(true); + attrs.push_str(&format!("id=\"{}\" hat=\"{}\"", xml_escape(&id), hat)); + } + "rainbow" | "gradient" | "transition" | "pride" => { + let raw = args.join(":"); + attrs.push_str(&format!("raw=\"{}\"", xml_escape(&raw))); + } + "font" => { + let font = args.first().cloned().unwrap_or_default(); + attrs.push_str(&format!("font=\"{}\"", xml_escape(&font))); + } + "newline" | "br" => return Ok("
".into()), + _ => return Ok(String::new()), // ignore unknown tags + } + + let mut start = format!("<{}", name); + if !attrs.is_empty() { + start.push(' '); + start.push_str(&attrs); + } + if self_closing { + start.push_str("/>"); + } else { + start.push('>'); + } + if !inner.is_empty() { + start.push_str(&inner); + } + Ok(start) +} + +fn is_container_tag(name: &str) -> bool { + matches!( + name, + "color" | "c" | "colour" + | "shadow" + | "bold" | "b" + | "italic" | "em" | "i" + | "underlined" | "u" + | "strikethrough" | "st" + | "obfuscated" | "obf" + | "click" + | "hover" + | "insertion" | "insert" + | "selector" | "sel" + | "lang" | "tr" | "translate" + | "lang_or" | "tr_or" | "translate_or" + | "nbt" | "data" + | "sprite" + | "head" + | "rainbow" | "gradient" | "transition" + | "font" + | "pride" + ) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +// ---------- XML to TextComponent ---------- + +fn parse_children( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + end_name: Option<&[u8]>, +) -> Result, MiniMessageError> { + let mut components = Vec::new(); + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::Start(e) => { + let tag_name = e.name().0.to_vec(); + let attrs = parse_attributes(&e); + let comp = handle_element_from_parts(reader, buf, tag_name, attrs)?; + components.push(comp); + } + Event::Empty(e) => { + let tag_name = e.name().0.to_vec(); + let attrs = parse_attributes(&e); + let comp = handle_self_closing_from_parts(&tag_name, &attrs)?; + components.push(comp); + } + Event::Text(e) => { + let raw = e.xml11_content()?; + let text = unescape(&raw)?; + if !text.is_empty() { + components.push(TextComponent::plain(text.into_owned())); + } + } + Event::End(e) => { + if let Some(en) = end_name && e.name().as_ref() == en { + break; + } + } + Event::Eof => break, + _ => {} + } + } + Ok(components) +} + +fn handle_element_from_parts( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: Vec, + attrs: HashMap, +) -> Result { + let name = str::from_utf8(&tag_name) + .unwrap() + .to_lowercase(); + + match name.as_str() { + "lang" | "tr" | "translate" | "lang_or" | "tr_or" | "translate_or" => { + parse_translate_component(reader, buf, &tag_name, &attrs) + } + "selector" | "sel" => parse_selector_component(reader, buf, &tag_name, &attrs), + "score" => parse_score_component(reader, buf, &tag_name, &attrs), + "nbt" | "data" => parse_nbt_component(reader, buf, &tag_name, &attrs), + "sprite" => parse_sprite_component(reader, buf, &tag_name, &attrs), + "head" => parse_head_component(reader, buf, &tag_name, &attrs), + "rainbow" | "gradient" | "transition" | "pride" => { + let children = parse_children(reader, buf, Some(&tag_name))?; + let msg = children + .iter() + .map(|c| c.to_plain(&NoResolutor)) + .collect::(); + Ok(TextComponent::plain(msg)) + } + "hover" => { + let mut component = TextComponent::new(); + if !attrs.is_empty() { + component.interactions.hover = + Some(build_hover_from_attrs(&attrs)?); + } + let (hover_event, content) = + parse_hover_content(reader, buf, &tag_name)?; + if let Some(he) = hover_event { + component.interactions.hover = Some(he); + } + component.children = content; + Ok(component) + } + _ => { + let mut component = TextComponent::new(); + apply_format_from_tag(&name, &attrs, &mut component); + apply_interactivity_from_tag(&name, &attrs, &mut component)?; + let children = parse_children(reader, buf, Some(&tag_name))?; + component.children = children; + Ok(component) + } + } +} + +fn handle_self_closing_from_parts( + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let name = str::from_utf8(tag_name).unwrap().to_lowercase(); + match name.as_str() { + "reset" => { + let mut comp = TextComponent::new(); + comp.format = Format::new().reset(); + Ok(comp) + } + "br" | "newline" => Ok(TextComponent::plain("\n")), + "keybind" | "key" => { + let key = attrs.get("key").cloned().unwrap_or_default(); + Ok(TextComponent { + content: Content::Keybind { + keybind: Cow::Owned(key), + }, + ..Default::default() + }) + } + _ => { + let mut comp = TextComponent::new(); + apply_format_from_tag(&name, attrs, &mut comp); + apply_interactivity_from_tag(&name, attrs, &mut comp)?; + Ok(comp) + } + } +} + +// ---------- Specific tag parsers ---------- + +fn parse_translate_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let key = attrs.get("key").cloned().unwrap_or_default(); + let fallback = attrs.get("fallback").cloned(); + let mut args: Vec = Vec::new(); + + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::Start(e) if e.name().as_ref() == b"arg" => { + let arg_comps = parse_children(reader, buf, Some(b"arg"))?; + if arg_comps.len() == 1 { + args.push(arg_comps.into_iter().next().unwrap()); + } else if !arg_comps.is_empty() { + args.push(TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children: arg_comps, + format: Format::new(), + interactions: Interactivity::new(), + }); + } + } + Event::End(e) if e.name().as_ref() == tag_name => break, + Event::Text(_) => {} // ignore whitespace + Event::Eof => break, + _ => {} + } + } + + let message = crate::translation::TranslatedMessage { + key: Cow::Owned(key), + fallback: fallback.map(Cow::Owned), + args: if args.is_empty() { + None + } else { + Some(args.into_boxed_slice()) + }, + }; + + Ok(TextComponent { + content: Content::Translate(message), + ..Default::default() + }) +} + +fn parse_selector_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let selector = attrs.get("selector").cloned().unwrap_or_default(); + let mut separator: Option = None; + + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::Start(e) if e.name().as_ref() == b"separator" => { + let sep_comps = parse_children(reader, buf, Some(b"separator"))?; + separator = Some(if sep_comps.len() == 1 { + sep_comps.into_iter().next().unwrap() + } else { + TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children: sep_comps, + format: Format::new(), + interactions: Interactivity::new(), + } + }); + } + Event::End(e) if e.name().as_ref() == tag_name => break, + Event::Text(_) => {} + Event::Eof => break, + _ => {} + } + } + + let separator = separator + .map(Box::new) + .unwrap_or_else(Resolvable::entity_separator); + Ok(TextComponent { + content: Content::Resolvable(Resolvable::Entity { + selector: Cow::Owned(selector), + separator, + }), + ..Default::default() + }) +} + +fn parse_score_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let name = attrs.get("name").cloned().unwrap_or_default(); + let objective = attrs.get("objective").cloned().unwrap_or_default(); + skip_to_end(reader, buf, tag_name)?; + Ok(TextComponent { + content: Content::Resolvable(Resolvable::Scoreboard { + selector: Cow::Owned(name), + objective: Cow::Owned(objective), + }), + ..Default::default() + }) +} + +fn parse_nbt_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let source_str = attrs.get("source").cloned().unwrap_or_default(); + let id = attrs.get("id").cloned().unwrap_or_default(); + let path = attrs.get("path").cloned().unwrap_or_default(); + let interpret = attrs.get("interpret").map(|_| true); + let mut separator = None; + + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::Start(e) if e.name().as_ref() == b"separator" => { + let sep_comps = parse_children(reader, buf, Some(b"separator"))?; + separator = Some(if sep_comps.len() == 1 { + sep_comps.into_iter().next().unwrap() + } else { + TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children: sep_comps, + format: Format::new(), + interactions: Interactivity::new(), + } + }); + } + Event::End(e) if e.name().as_ref() == tag_name => break, + Event::Text(_) => {} + Event::Eof => break, + _ => {} + } + } + + let source = match source_str.as_str() { + "block" => NbtSource::Block(Cow::Owned(id)), + "entity" => NbtSource::Entity(Cow::Owned(id)), + "storage" => NbtSource::Storage(Cow::Owned(id)), + _ => { + return Err(MiniMessageError::InvalidTag(format!( + "unknown nbt source: {}", + source_str + ))) + } + }; + let separator = separator + .map(Box::new) + .unwrap_or_else(Resolvable::nbt_separator); + Ok(TextComponent { + content: Content::Resolvable(Resolvable::NBT { + path: Cow::Owned(path), + interpret, + separator, + source, + }), + ..Default::default() + }) +} + +fn parse_sprite_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let atlas = attrs.get("atlas").cloned(); + let sprite = attrs.get("sprite").cloned().unwrap_or_default(); + skip_to_end(reader, buf, tag_name)?; + Ok(TextComponent { + content: Content::Object(Object::Atlas { + atlas: atlas.map(Cow::Owned), + sprite: Cow::Owned(sprite), + }), + ..Default::default() + }) +} + +fn parse_head_component( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], + attrs: &HashMap, +) -> Result { + let id = attrs.get("id").cloned().unwrap_or_default(); + let hat = attrs.get("hat").map(|h| h != "false").unwrap_or(true); + skip_to_end(reader, buf, tag_name)?; + + let player = if let Ok(uuid) = Uuid::parse_str(&id) { + let (high, low) = uuid.as_u64_pair(); + let arr = [ + ((high >> 32) & 0xFFFFFFFF) as i32, + (high & 0xFFFFFFFF) as i32, + ((low >> 32) & 0xFFFFFFFF) as i32, + (low & 0xFFFFFFFF) as i32, + ]; + ObjectPlayer { + id: Some(arr), + name: None, + texture: None, + properties: Vec::new(), + } + } else if id.contains('/') { + ObjectPlayer::texture(id) + } else { + ObjectPlayer::name(id) + }; + + Ok(TextComponent { + content: Content::Object(Object::Player { player, hat }), + ..Default::default() + }) +} + +/// Parse the content of a `` element. +fn parse_hover_content( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + parent_tag: &[u8], +) -> Result<(Option, Vec), MiniMessageError> { + let mut hover_event = None; + let mut children = Vec::new(); + + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::Start(e) => { + let sub_name = str::from_utf8(e.name().as_ref()) + .unwrap() + .to_lowercase(); + if sub_name == "value" { + let inner = parse_children(reader, buf, Some(b"value"))?; + let text = if inner.is_empty() { + TextComponent::new() + } else if inner.len() == 1 { + inner.into_iter().next().unwrap() + } else { + TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children: inner, + format: Format::new(), + interactions: Interactivity::new(), + } + }; + hover_event = Some(HoverEvent::ShowText { + value: Box::new(text), + }); + } else if sub_name == "name" { + let inner = parse_children(reader, buf, Some(b"name"))?; + let name = if inner.is_empty() { + None + } else if inner.len() == 1 { + Some(Box::new(inner.into_iter().next().unwrap())) + } else { + Some(Box::new(TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + children: inner, + format: Format::new(), + interactions: Interactivity::new(), + })) + }; + if let Some(ref mut existing) = hover_event { + if let HoverEvent::ShowEntity { id, uuid, .. } = existing { + *existing = HoverEvent::ShowEntity { + name, + id: id.clone(), + uuid: *uuid, + }; + } + } else { + hover_event = Some(HoverEvent::ShowText { + value: Box::new(TextComponent::new()), + }); + } + } else { + // treat as normal child – extract name/attrs to avoid borrow conflicts + let tag_name = e.name().0.to_vec(); + let attrs = parse_attributes(&e); + let comp = handle_element_from_parts(reader, buf, tag_name, attrs)?; + children.push(comp); + } + } + Event::End(e) if e.name().as_ref() == parent_tag => break, + Event::Text(e) => { + let raw = e.xml11_content()?; + let text = unescape(&raw)?; + if !text.is_empty() { + children.push(TextComponent::plain(text.into_owned())); + } + } + Event::Eof => break, + _ => {} + } + } + + Ok((hover_event, children)) +} + +fn skip_to_end( + reader: &mut Reader<&[u8]>, + buf: &mut Vec, + tag_name: &[u8], +) -> Result<(), MiniMessageError> { + loop { + buf.clear(); + match reader.read_event_into(buf)? { + Event::End(e) if e.name().as_ref() == tag_name => return Ok(()), + Event::Eof => return Ok(()), + _ => {} + } + } +} + +// ---------- Utilities ---------- + +fn parse_attributes(start: &BytesStart) -> HashMap { + let mut map = HashMap::new(); + for attr in start.attributes().flatten() { + let key = str::from_utf8(attr.key.as_ref()) + .unwrap() + .to_lowercase(); + let value = attr + .decode_and_unescape_value(start.decoder()) + .unwrap_or_default() + .into_owned(); + map.insert(key, value); + } + map +} + +fn apply_format_from_tag(name: &str, attrs: &HashMap, comp: &mut TextComponent) { + match name { + "color" | "c" | "colour" => { + if let Some(col) = attrs.get("name") + && let Some(color) = Color::from_hex(col).or_else(|| Color::from_name(col)) { + comp.format.color = Some(color); + } + } + "shadow" => { + if let Some(col) = attrs.get("color") { + let alpha = attrs + .get("alpha") + .and_then(|a| a.parse::().ok()) + .unwrap_or(0.25); + if let Some(color) = parse_color_with_alpha(col, alpha) { + comp.format.shadow_color = Some(color); + } + } + } + "bold" | "b" => comp.format.bold = Some(true), + "italic" | "em" | "i" => comp.format.italic = Some(true), + "underlined" | "u" => comp.format.underlined = Some(true), + "strikethrough" | "st" => comp.format.strikethrough = Some(true), + "obfuscated" | "obf" => comp.format.obfuscated = Some(true), + "font" => { + if let Some(f) = attrs.get("font") { + comp.format.font = Some(Cow::Owned(f.clone())); + } + } + _ => {} + } +} + +fn parse_color_with_alpha(color_str: &str, alpha: f32) -> Option { + let c = Color::from_hex(color_str)?; + if let Color::Rgb(r, g, b) = c { + let a = (alpha * 255.0) as u8; + Some(Format::parse_shadow_color(a, r, g, b)) + } else { + None + } +} + +fn apply_interactivity_from_tag( + name: &str, + attrs: &HashMap, + comp: &mut TextComponent, +) -> Result<(), MiniMessageError> { + match name { + "click" => { + let action = attrs.get("action").cloned().unwrap_or_default(); + let value = attrs.get("value").cloned().unwrap_or_default(); + let event = match action.as_str() { + "open_url" => ClickEvent::OpenUrl { + url: Cow::Owned(value), + }, + "run_command" => ClickEvent::RunCommand { + command: Cow::Owned(value), + }, + "suggest_command" => ClickEvent::SuggestCommand { + command: Cow::Owned(value), + }, + "change_page" => { + let page = value.parse::().unwrap_or(1); + ClickEvent::ChangePage { page } + } + "copy_to_clipboard" => ClickEvent::CopyToClipboard { + value: Cow::Owned(value), + }, + "show_dialog" => ClickEvent::ShowDialog { + dialog: Cow::Owned(value), + }, + _ => { + return Err(MiniMessageError::InvalidTag(format!( + "click action: {}", + action + ))) + } + }; + comp.interactions.click = Some(event); + } + "insertion" | "insert" => { + let val = attrs.get("value").cloned().unwrap_or_default(); + comp.interactions.insertion = Some(Cow::Owned(val)); + } + _ => {} + } + Ok(()) +} + +fn build_hover_from_attrs( + attrs: &HashMap, +) -> Result { + let action = attrs.get("action").cloned().unwrap_or_default(); + match action.as_str() { + "show_item" => { + let id = attrs.get("item").cloned().unwrap_or_default(); + let count = attrs.get("count").and_then(|c| c.parse().ok()); + let components = attrs.get("tag").cloned(); + Ok(HoverEvent::ShowItem { + id: Cow::Owned(id), + count, + components: components.map(Cow::Owned), + }) + } + "show_entity" => { + let etype = attrs.get("type").cloned().unwrap_or_default(); + let uuid_str = attrs.get("uuid").cloned().unwrap_or_default(); + let uuid = Uuid::parse_str(&uuid_str).unwrap_or(Uuid::nil()); + Ok(HoverEvent::ShowEntity { + name: None, + id: Cow::Owned(etype), + uuid, + }) + } + _ => Err(MiniMessageError::InvalidTag(format!( + "hover action: {}", + action + ))), + } +} + +impl Color { + pub fn from_name(name: &str) -> Option { + match name { + "black" => Some(Color::Black), + "dark_blue" => Some(Color::DarkBlue), + "dark_green" => Some(Color::DarkGreen), + "dark_aqua" => Some(Color::DarkAqua), + "dark_red" => Some(Color::DarkRed), + "dark_purple" => Some(Color::DarkPurple), + "gold" => Some(Color::Gold), + "gray" | "grey" => Some(Color::Gray), + "dark_gray" | "dark_grey" => Some(Color::DarkGray), + "blue" => Some(Color::Blue), + "green" => Some(Color::Green), + "aqua" => Some(Color::Aqua), + "red" => Some(Color::Red), + "light_purple" => Some(Color::LightPurple), + "yellow" => Some(Color::Yellow), + "white" => Some(Color::White), + _ => None, + } + } +} \ No newline at end of file From 6083e5748e759c66dc8b209fccd6d9c12a071678 Mon Sep 17 00:00:00 2001 From: suprohub Date: Mon, 4 May 2026 18:22:59 +0300 Subject: [PATCH 02/15] AI v2 --- Cargo.lock | 10 - Cargo.toml | 1 - src/format.rs | 6 +- src/minimessage.rs | 2537 +++++++++++++++++++++++++++----------------- 4 files changed, 1548 insertions(+), 1006 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc9b1fa..66c0d13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,15 +341,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.39.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" -dependencies = [ - "memchr", -] - [[package]] name = "quote" version = "1.0.45" @@ -536,7 +527,6 @@ dependencies = [ "colored", "heck", "proc-macro2", - "quick-xml", "quote", "rand", "rustc-hash", diff --git a/Cargo.toml b/Cargo.toml index 2948510..56c6392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,6 @@ 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" -quick-xml = "0.39" [dev-dependencies] chrono = "0.4" diff --git a/src/format.rs b/src/format.rs index cc89888..9116337 100644 --- a/src/format.rs +++ b/src/format.rs @@ -129,9 +129,9 @@ impl Format { pub fn mix(&self, other: &Format) -> Format { 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 +172,7 @@ impl Format { } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum Color { diff --git a/src/minimessage.rs b/src/minimessage.rs index 83c8b33..d1a6a71 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -1,1132 +1,1685 @@ -// src/minimessage.rs +// minimessage.rs use crate::{ - content::{Content, Object, ObjectPlayer, Resolvable, NbtSource}, - format::{Color, Format}, - interactivity::{ClickEvent, HoverEvent, Interactivity}, - resolving::NoResolutor, TextComponent, + content::{Content, NbtSource, Object, ObjectPlayer, Resolvable}, + format::{Color, Format}, + interactivity::{ClickEvent, HoverEvent}, + translation::TranslatedMessage, }; -use quick_xml::escape::unescape; -use quick_xml::events::{BytesStart, Event}; -use quick_xml::Reader; -use quick_xml::encoding::EncodingError; -use quick_xml::escape::EscapeError; use std::borrow::Cow; -use std::collections::HashMap; -use std::str; -use uuid::Uuid; - -/// Error type for MiniMessage parsing. -#[derive(Debug)] -pub enum MiniMessageError { - Io(quick_xml::Error), - InvalidTag(String), - MissingArgument(String), - InvalidContent(String), -} -impl std::fmt::Display for MiniMessageError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - MiniMessageError::Io(e) => write!(f, "IO error: {}", e), - MiniMessageError::InvalidTag(s) => write!(f, "Invalid tag: {}", s), - MiniMessageError::MissingArgument(s) => write!(f, "Missing argument in tag: {}", s), - MiniMessageError::InvalidContent(s) => write!(f, "Invalid content: {}", s), - } - } -} +#[cfg(feature = "custom")] +use crate::custom::{CustomData, Payload}; -impl std::error::Error for MiniMessageError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - MiniMessageError::Io(e) => Some(e), - _ => None, - } - } +pub fn parse(input: &str) -> TextComponent { + Parser::parse(input) } -impl From for MiniMessageError { - fn from(e: quick_xml::Error) -> Self { - MiniMessageError::Io(e) +fn new_component(content: Content) -> TextComponent { + TextComponent { + content, + ..Default::default() } } -impl From for MiniMessageError { - fn from(e: EncodingError) -> Self { - MiniMessageError::InvalidContent(format!("Encoding error: {}", e)) - } +struct Parser { + /// All created nodes. Node 0 is the implicit root. + nodes: Vec, + /// children[i] contains indices of direct children of node i. + children: Vec>, + /// Stack of currently open wrapper tags: (node_index, tag_name_lowercase). + stack: Vec<(usize, String)>, } -impl From for MiniMessageError { - fn from(e: EscapeError) -> Self { - MiniMessageError::InvalidContent(format!("Escape error: {}", e)) - } -} +impl Parser { + fn parse(input: &str) -> TextComponent { + let mut parser = Parser { + nodes: vec![TextComponent::new()], + children: vec![Vec::new()], + stack: vec![(0, String::new())], + }; + let len = input.len(); + let mut i = 0; -/// Parses a MiniMessage string and returns a `TextComponent`. -pub fn from_minimessage(input: &str) -> Result { - let xml = minimessage_to_xml(input)?; - let mut reader = Reader::from_reader(xml.as_bytes()); - reader.config_mut().trim_text(true); - - let mut buf = Vec::new(); - loop { - match reader.read_event_into(&mut buf)? { - Event::Start(ref e) if e.name().as_ref() == b"root" => { - let children = parse_children(&mut reader, &mut buf, Some(b"root"))?; - if children.is_empty() { - return Ok(TextComponent::new()); - } - if children.len() == 1 { - return Ok(children.into_iter().next().unwrap()); - } else { - return Ok(TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children, - format: Format::new(), - interactions: Interactivity::new(), - }); + while i < len { + // 1. Collect plain text until '<' + let start = i; + while i < len && input.as_bytes()[i] != b'<' { + i += 1; + } + if i > start { + let text = unescape_text(&input[start..i]); + if !text.is_empty() { + let comp = TextComponent::plain(text.into_owned()); + let parent = parser.stack.last().unwrap().0; + parser.add_child_node(parent, comp); } } - Event::Eof => break, - _ => {} // skip potential XML declaration - } - } - Err(MiniMessageError::InvalidContent( - "no root element found".into(), - )) -} - -// ---------- MiniMessage to XML ---------- + if i >= len { + break; + } -fn minimessage_to_xml(input: &str) -> Result { - let mut xml = String::from(""); - xml.push_str(&preprocess_minimessage(input)?); - xml.push_str(""); - Ok(xml) -} + // 2. Skip '<' + i += 1; + if i >= len { + break; + } -fn preprocess_minimessage(input: &str) -> Result { - let mut output = String::new(); - let chars: Vec = input.chars().collect(); - let len = chars.len(); - let mut pos = 0; - - while pos < len { - // Escape backslash: just output the next character literally - if chars[pos] == '\\' && pos + 1 < len { - output.push(chars[pos + 1]); - pos += 2; - continue; - } - if chars[pos] == '<' { - pos += 1; // skip '<' - // Closing tag - if pos < len && chars[pos] == '/' { - pos += 1; - let mut name = String::new(); - while pos < len && chars[pos] != '>' { - name.push(chars[pos]); - pos += 1; + // 3. Closing tag? + if input.as_bytes()[i] == b'/' { + i += 1; + let end = i; + while i < len && input.as_bytes()[i] != b'>' { + i += 1; } - if pos >= len { - return Err(MiniMessageError::InvalidTag( - "unclosed closing tag".into(), - )); + let tag_name = &input[end..i]; + if i < len { + i += 1; } - pos += 1; // '>' - output.push_str("'); + parser.close_tag(tag_name); continue; } - let mut tag_body = String::new(); + // 4. Opening or self-closing tag + let tag_start = i; + while i < len { + match input.as_bytes()[i] { + b'>' | b':' | b'/' => break, + _ => i += 1, + } + } + let tag_name = &input[tag_start..i]; + let mut args = Vec::new(); let mut self_closing = false; - while pos < len { - if chars[pos] == '/' && pos + 1 < len && chars[pos + 1] == '>' { - self_closing = true; - pos += 2; - break; + + if i < len && input.as_bytes()[i] == b':' { + i += 1; + args = split_args(input, &mut i, len); + } + if i < len && input.as_bytes()[i] == b'/' { + self_closing = true; + i += 1; + } + if i < len && input.as_bytes()[i] == b'>' { + i += 1; + } else { + // skip invalid + while i < len && input.as_bytes()[i] != b'>' { + i += 1; } - if chars[pos] == '>' { - pos += 1; - break; + if i < len { + i += 1; } - tag_body.push(chars[pos]); - pos += 1; } - let colon_pos = tag_body.find(':'); - let (raw_name, args_str) = if let Some(p) = colon_pos { - (&tag_body[..p], &tag_body[p + 1..]) - } else { - (tag_body.as_str(), "") - }; + parser.process_open_tag(tag_name, args, self_closing); + } + + parser.finish() + } + + /// Add a child component to `parent`, return the new node index. + fn add_child_node(&mut self, parent: usize, child: TextComponent) -> usize { + let idx = self.nodes.len(); + self.nodes.push(child); + self.children.push(Vec::new()); + self.children[parent].push(idx); + idx + } + + /// Push a wrapper: add child component and optionally push its index + tag name onto the stack. + fn push_tag_to_stack( + &mut self, + parent: usize, + comp: TextComponent, + tag_name: String, // already lowercased, owned + self_closing: bool, + ) -> usize { + let idx = self.add_child_node(parent, comp); + if !self_closing { + self.stack.push((idx, tag_name)); + } + idx + } + + /// Convenience: push a formatting-only wrapper (with empty text content). + fn push_format_wrapper( + &mut self, + parent: usize, + format: Format, + tag_name: String, + self_closing: bool, + ) { + let comp = TextComponent { + content: Content::Text { + text: Cow::Borrowed(""), + }, + format, + ..Default::default() + }; + self.push_tag_to_stack(parent, comp, tag_name, self_closing); + } + + /// Close the nearest open tag with the given name. + fn close_tag(&mut self, tag_name: &str) { + let tag = tag_name.to_lowercase(); + if tag == "reset" { + return; // reset cannot be closed + } + if let Some(pos) = self.stack.iter().rposition(|(_, name)| *name == tag) { + self.stack.truncate(pos); + } + } - let mut name = raw_name.trim().to_lowercase(); - let mut args = parse_mm_args(args_str)?; + /// Entry point for any opening or self-closing tag. + fn process_open_tag(&mut self, tag_name: &str, args: Vec>, self_closing: bool) { + let parent = self.stack.last().map(|s| s.0).unwrap_or(0); + let tag_lower = tag_name.to_lowercase(); - // Handle negative decorator etc. - if let Some(real_name) = name.strip_prefix('!') { - // act as self-closing tag that disables the decoration - return Ok(format!("<{}/>", real_name)); + match tag_lower.as_str() { + // Decorations + "b" | "bold" | "!b" | "!bold" | "i" | "em" | "italic" | "!i" | "!em" | "!italic" + | "u" | "underlined" | "!u" | "!underlined" | "st" | "strikethrough" | "!st" + | "!strikethrough" | "obf" | "obfuscated" | "!obf" | "!obfuscated" => { + self.handle_decoration_tag(&tag_lower, parent, self_closing); } - // Detect standalone color (short name or hex code) - if is_standalone_color(&name) { - let original_color = name.clone(); - name = "color".to_string(); - args.insert(0, original_color); // use the original color value + // Reset + "reset" => { + self.stack.truncate(1); + // reset itself is never pushed onto the stack } - let xml_fragment = mini_tag_to_xml(&name, &args, self_closing)?; - output.push_str(&xml_fragment); + // Shadow + "shadow" => { + self.handle_shadow_tag(args, parent, self_closing); + } + "!shadow" => { + // Fully transparent shadow + let mut fmt = Format::new(); + fmt.shadow_color = Some(0); + self.push_format_wrapper(parent, fmt, tag_lower, self_closing); + } - if !self_closing && is_container_tag(&name) { - let remaining = &input[pos..]; - if let Some((content, end_pos)) = find_closing_tag(remaining, &name) { - let inner_xml = preprocess_minimessage(content)?; - output.push_str(&inner_xml); - output.push_str("'); - pos += end_pos; - } else { - // No closing tag – loose mode: consume rest of string - let inner_xml = preprocess_minimessage(&input[pos..])?; - output.push_str(&inner_xml); - output.push_str("'); - pos = len; - } + // Verbose color + "color" | "c" | "colour" => { + self.handle_verbose_color_tag(tag_lower, args, parent, self_closing); } - } else { - let start = pos; - while pos < len && chars[pos] != '<' && chars[pos] != '\\' { - pos += 1; + + // Click + "click" => { + self.handle_click_tag(args, parent, self_closing); } - let text: String = chars[start..pos].iter().collect(); - output.push_str(&xml_escape(&text)); - } - } - Ok(output) -} -fn find_closing_tag<'a>(s: &'a str, name: &str) -> Option<(&'a str, usize)> { - let closing = format!("", name); - if let Some(idx) = s.find(&closing) { - Some((&s[..idx], idx + closing.len())) - } else { - None - } -} + // Hover + "hover" => { + self.handle_hover_tag(args, parent, self_closing); + } -fn parse_mm_args(args: &str) -> Result, MiniMessageError> { - let mut result = Vec::new(); - let chars: Vec = args.chars().collect(); - let mut i = 0; - while i < chars.len() { - if chars[i] == '\'' || chars[i] == '"' { - let quote = chars[i]; - i += 1; - let start = i; - while i < chars.len() && chars[i] != quote { - i += 1; + // Insertion + "insert" => { + self.handle_insertion_tag(args, parent, self_closing); } - if i >= chars.len() { - return Err(MiniMessageError::InvalidContent("unclosed quote".into())); + + // Font + "font" => { + self.handle_font_tag(args, parent, self_closing); } - let val: String = chars[start..i].iter().collect(); - result.push(val); - i += 1; // skip closing quote - } else { - let start = i; - while i < chars.len() && chars[i] != ':' { - i += 1; + + // Keybind + "key" => { + self.handle_keybind_tag(args, parent); } - let val: String = chars[start..i].iter().collect(); - result.push(val.trim().to_string()); - } - if i < chars.len() && chars[i] == ':' { - i += 1; - } - } - Ok(result) -} -fn is_standalone_color(name: &str) -> bool { - Color::from_name(name).is_some() || name.starts_with('#') -} + // Translatable + "lang" | "tr" | "translate" => { + self.handle_translate_tag(args, parent, None); + } + "lang_or" | "tr_or" | "translate_or" => { + self.handle_translate_tag(args, parent, Some(true)); + } -fn mini_tag_to_xml( - name: &str, - args: &[String], - self_closing: bool, -) -> Result { - let mut attrs = String::new(); - let mut inner = String::new(); - - match name { - "color" | "c" | "colour" => { - let col = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("color".into()))?; - attrs.push_str(&format!("name=\"{}\"", xml_escape(&col))); - } - "shadow" => { - let col = args.first().cloned().unwrap_or_default(); - let alpha = args.get(1).cloned().unwrap_or_else(|| "0.25".into()); - attrs.push_str(&format!( - "color=\"{}\" alpha=\"{}\"", - xml_escape(&col), - alpha - )); - } - "bold" | "b" | "italic" | "em" | "i" | "underlined" | "u" | "strikethrough" | "st" - | "obfuscated" | "obf" => {} - "reset" => return Ok("".into()), - "click" => { - let action = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("click action".into()))?; - let value = args.get(1).cloned().unwrap_or_default(); - attrs.push_str(&format!( - "action=\"{}\" value=\"{}\"", - action, - xml_escape(&value) - )); - } - "hover" => { - let action = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("hover action".into()))?; - match action.as_str() { - "show_text" => { - let val = args.get(1).cloned().unwrap_or_default(); - let sub_xml = preprocess_minimessage(&val)?; - inner.push_str(&format!("{}", sub_xml)); - } - "show_item" => { - let item = args.get(1).cloned().unwrap_or_default(); - let count = args.get(2).cloned(); - let tag = args.get(3).cloned(); - attrs.push_str(&format!("item=\"{}\"", xml_escape(&item))); - if let Some(c) = count { - attrs.push_str(&format!(" count=\"{}\"", c)); - } - if let Some(t) = tag { - attrs.push_str(&format!(" tag=\"{}\"", xml_escape(&t))); - } - } - "show_entity" => { - let etype = args.get(1).cloned().unwrap_or_default(); - let uuid = args.get(2).cloned().unwrap_or_default(); - let name_mm = args.get(3).cloned(); - attrs.push_str(&format!( - "type=\"{}\" uuid=\"{}\"", - xml_escape(&etype), - uuid - )); - if let Some(n) = name_mm { - let n_xml = preprocess_minimessage(&n)?; - inner.push_str(&format!("{}", n_xml)); - } - } - _ => { - return Err(MiniMessageError::InvalidTag(format!( - "hover action: {}", - action - ))) - } + // Newline + "newline" | "br" => { + self.add_child_node(parent, TextComponent::plain("\n")); } - } - "keybind" | "key" => { - let key = args.first().cloned().unwrap_or_default(); - attrs.push_str(&format!("key=\"{}\"", xml_escape(&key))); - } - "lang" | "tr" | "translate" => { - let key = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("key".into()))?; - attrs.push_str(&format!("key=\"{}\"", xml_escape(&key))); - for arg_mm in &args[1..] { - let arg_xml = preprocess_minimessage(arg_mm)?; - inner.push_str(&format!("{}", arg_xml)); + + // Selector + "selector" | "sel" => { + self.handle_selector_tag(args, parent); } - } - "lang_or" | "tr_or" | "translate_or" => { - let key = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("key".into()))?; - let fallback = args - .get(1) - .cloned() - .ok_or(MiniMessageError::MissingArgument("fallback".into()))?; - attrs.push_str(&format!( - "key=\"{}\" fallback=\"{}\"", - xml_escape(&key), - xml_escape(&fallback) - )); - for arg_mm in &args[2..] { - let arg_xml = preprocess_minimessage(arg_mm)?; - inner.push_str(&format!("{}", arg_xml)); + + // Score + "score" => { + self.handle_score_tag(args, parent); } - } - "insertion" | "insert" => { - let val = args.first().cloned().unwrap_or_default(); - attrs.push_str(&format!("value=\"{}\"", xml_escape(&val))); - } - "selector" | "sel" => { - let sel = args.first().cloned().unwrap_or_default(); - attrs.push_str(&format!("selector=\"{}\"", xml_escape(&sel))); - if let Some(sep_mm) = args.get(1) { - let sep_xml = preprocess_minimessage(sep_mm)?; - inner.push_str(&format!("{}", sep_xml)); + + // NBT + "nbt" | "data" => { + self.handle_nbt_tag(args, parent); + } + + // Sprite + "sprite" => { + self.handle_sprite_tag(args, parent); + } + + // Head + "head" => { + self.handle_head_tag(args, parent); + } + + // Custom elements (only when feature "custom" is enabled) + #[cfg(feature = "custom")] + "rainbow" => { + // TODO: actually parse phase and direction arguments + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed("rainbow"), + payload: Payload::Empty, + })); + self.push_tag_to_stack(parent, comp, tag_lower, 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, tag_lower, 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, tag_lower, 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, tag_lower, self_closing); + } + + // Fallback: treat as a colour name or #hex code + _ => { + if let Some(color) = parse_color(&tag_lower) { + self.push_format_wrapper( + parent, + Format::new().color(color), + tag_lower, + self_closing, + ); + } else if tag_lower.starts_with('#') + && let Some(color) = Color::from_hex(&tag_lower) + { + self.push_format_wrapper( + parent, + Format::new().color(color), + tag_lower, + self_closing, + ); + } + // otherwise unknown tag → silently ignore (lenient mode) } } - "score" => { - let name = args.first().cloned().unwrap_or_default(); - let obj = args.get(1).cloned().unwrap_or_default(); - attrs.push_str(&format!( - "name=\"{}\" objective=\"{}\"", - xml_escape(&name), - xml_escape(&obj) - )); + } + + fn handle_decoration_tag(&mut self, tag: &str, parent: usize, self_closing: bool) { + let (decoration, value) = if let Some(rest) = tag.strip_prefix('!') { + (rest, false) + } else { + (tag, true) + }; + + let format = match decoration { + "b" | "bold" => Format::new().bold(value), + "i" | "em" | "italic" => Format::new().italic(value), + "u" | "underlined" => Format::new().underlined(value), + "st" | "strikethrough" => Format::new().strikethrough(value), + "obf" | "obfuscated" => Format::new().obfuscated(value), + _ => return, + }; + + // tag is already lowercased; we pass it as String (cloned) because we need ownership. + self.push_format_wrapper(parent, format, tag.to_string(), self_closing); + } + + fn handle_shadow_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + let format = parse_shadow(&args); + self.push_format_wrapper(parent, format, "shadow".to_string(), self_closing); + } + + fn handle_verbose_color_tag( + &mut self, + tag: String, + args: Vec>, + parent: usize, + self_closing: bool, + ) { + 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_wrapper(parent, format, tag, self_closing); + } + + fn handle_click_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + if args.len() >= 2 { + let action = take_arg(&args[0]); + let value: String = args[1..] + .iter() + .map(|a| a.as_ref()) + .collect::>() + .join(":"); + let click = parse_click(&action, &value); + let comp = new_component(Content::Text { + text: Cow::Borrowed(""), + }); + let mut comp = comp; + comp.interactions.click = click; + self.push_tag_to_stack(parent, comp, "click".to_string(), self_closing); } - "nbt" | "data" => { - let source = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("nbt source".into()))?; - let id = args.get(1).cloned().unwrap_or_default(); - let path = args.get(2).cloned().unwrap_or_default(); - attrs.push_str(&format!( - "source=\"{}\" id=\"{}\" path=\"{}\"", - source, id, path - )); - let mut idx = 3; - if let Some(sep_mm) = args.get(3) && sep_mm != "interpret" { - let sep_xml = preprocess_minimessage(sep_mm)?; - inner.push_str(&format!("{}", sep_xml)); - idx += 1; - } - if args.get(idx).map(|s| s.as_str()) == Some("interpret") { - attrs.push_str(" interpret=\"true\""); - } + } + + fn handle_hover_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + 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, "hover".to_string(), self_closing); + } + + fn handle_insertion_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + if let Some(text) = args.first() { + let mut comp = new_component(Content::Text { + text: Cow::Borrowed(""), + }); + comp.interactions.insertion = Some(Cow::Owned(take_arg(text))); + self.push_tag_to_stack(parent, comp, "insert".to_string(), self_closing); } - "sprite" => { - let atlas = args - .first() - .cloned() - .unwrap_or_else(|| "minecraft:blocks".into()); - let sprite = args - .get(1) - .cloned() - .ok_or(MiniMessageError::MissingArgument("sprite".into()))?; - attrs.push_str(&format!( - "atlas=\"{}\" sprite=\"{}\"", - xml_escape(&atlas), - xml_escape(&sprite) - )); + } + + fn handle_font_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + let font = args + .into_iter() + .map(|a| match a { + Cow::Owned(s) => s, + Cow::Borrowed(s) => s.to_string(), + }) + .collect::>() + .join(":"); + self.push_format_wrapper( + parent, + Format::new().font(font), + "font".to_string(), + self_closing, + ); + } + + fn handle_keybind_tag(&mut self, args: Vec>, parent: usize) { + let keybind = args + .into_iter() + .map(|a| match a { + Cow::Owned(s) => s, + Cow::Borrowed(s) => s.to_string(), + }) + .collect::>() + .join(":"); + let comp = new_component(Content::Keybind { + keybind: Cow::Owned(keybind), + }); + self.add_child_node(parent, comp); + } + + fn handle_translate_tag( + &mut self, + args: Vec>, + parent: usize, + has_fallback: Option, + ) { + let mut args = args; + let (key, fallback) = match has_fallback { + None => (take_first_arg(&mut args), None), + Some(_) => { + let key = take_first_arg(&mut args); + let fb = take_first_arg(&mut args).map(Cow::Owned); + (key, fb) + } + }; + + if let Some(key) = key { + let t_args: Vec = args + .into_iter() + .map(|a| parse_minimessage(a.as_ref())) + .collect(); + let msg = TranslatedMessage { + key: Cow::Owned(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); } - "head" => { - let id = args - .first() - .cloned() - .ok_or(MiniMessageError::MissingArgument("head id".into()))?; - let hat = args.get(1).map(|s| s != "false").unwrap_or(true); - attrs.push_str(&format!("id=\"{}\" hat=\"{}\"", xml_escape(&id), hat)); + } + + fn handle_selector_tag(&mut self, args: Vec>, 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) { + Box::new(parse_minimessage(&sep)) + } else { + Resolvable::entity_separator() + }; + let resolvable = Resolvable::Entity { + selector: Cow::Owned(sel), + separator, + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); } - "rainbow" | "gradient" | "transition" | "pride" => { - let raw = args.join(":"); - attrs.push_str(&format!("raw=\"{}\"", xml_escape(&raw))); + } + + fn handle_score_tag(&mut self, args: Vec>, parent: usize) { + 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: Cow::Owned(name), + objective: Cow::Owned(objective), + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); } - "font" => { - let font = args.first().cloned().unwrap_or_default(); - attrs.push_str(&format!("font=\"{}\"", xml_escape(&font))); + } + + fn handle_nbt_tag(&mut self, args: Vec>, parent: usize) { + let args = args; + if args.len() >= 3 { + let source_type = take_arg(&args[0]); + let id = take_arg(&args[1]); + let path = take_arg(&args[2]); + let separator = if args.get(3).is_some() { + let sep = take_arg(&args[3]); + Box::new(parse_minimessage(&sep)) + } else { + Resolvable::nbt_separator() + }; + let interpret = args.get(4).is_some_and(|v| v.as_ref() == "interpret"); + let source = match source_type.as_str() { + "entity" => NbtSource::Entity(Cow::Owned(id)), + "block" => NbtSource::Block(Cow::Owned(id)), + "storage" => NbtSource::Storage(Cow::Owned(id)), + _ => return, + }; + let resolvable = Resolvable::NBT { + path: Cow::Owned(path), + interpret: if interpret { Some(true) } else { None }, + separator, + source, + }; + let comp = new_component(Content::Resolvable(resolvable)); + self.add_child_node(parent, comp); } - "newline" | "br" => return Ok("
".into()), - _ => return Ok(String::new()), // ignore unknown tags } - let mut start = format!("<{}", name); - if !attrs.is_empty() { - start.push(' '); - start.push_str(&attrs); + fn handle_sprite_tag(&mut self, args: Vec>, parent: usize) { + let mut args = args; + let (atlas, sprite) = if args.len() == 1 { + (None, take_first_arg(&mut args).unwrap_or_default()) + } else if args.len() >= 2 { + let atlas = take_first_arg(&mut args); + let sprite = take_first_arg(&mut args).unwrap_or_default(); + (atlas, sprite) + } else { + return; + }; + let comp = new_component(Content::Object(Object::Atlas { + atlas: atlas.map(Cow::Owned), + sprite: Cow::Owned(sprite), + })); + self.add_child_node(parent, comp); + } + + fn handle_head_tag(&mut self, args: Vec>, 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) { + 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.contains('/') || head_str.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); + } } - if self_closing { - start.push_str("/>"); - } else { - start.push('>'); + + fn finish(mut self) -> TextComponent { + // close any remaining open wrappers (root is always at 0) + self.stack.truncate(1); + self.build_node(0) } - if !inner.is_empty() { - start.push_str(&inner); + + fn build_node(&mut self, idx: usize) -> TextComponent { + 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 } - Ok(start) } -fn is_container_tag(name: &str) -> bool { - matches!( - name, - "color" | "c" | "colour" - | "shadow" - | "bold" | "b" - | "italic" | "em" | "i" - | "underlined" | "u" - | "strikethrough" | "st" - | "obfuscated" | "obf" - | "click" - | "hover" - | "insertion" | "insert" - | "selector" | "sel" - | "lang" | "tr" | "translate" - | "lang_or" | "tr_or" | "translate_or" - | "nbt" | "data" - | "sprite" - | "head" - | "rainbow" | "gradient" | "transition" - | "font" - | "pride" - ) +/// Extract an owned String from a Cow, cloning only when necessary. +fn take_arg(cow: &Cow) -> String { + match cow { + Cow::Borrowed(s) => (*s).to_string(), + Cow::Owned(s) => s.clone(), + } } -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") +/// Remove the first element of a Vec> and convert it to an owned String. +fn take_first_arg(args: &mut Vec>) -> Option { + if args.is_empty() { + return None; + } + let cow = args.remove(0); + Some(match cow { + Cow::Borrowed(s) => s.to_string(), + Cow::Owned(s) => s, + }) } -// ---------- XML to TextComponent ---------- - -fn parse_children( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - end_name: Option<&[u8]>, -) -> Result, MiniMessageError> { - let mut components = Vec::new(); - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::Start(e) => { - let tag_name = e.name().0.to_vec(); - let attrs = parse_attributes(&e); - let comp = handle_element_from_parts(reader, buf, tag_name, attrs)?; - components.push(comp); - } - Event::Empty(e) => { - let tag_name = e.name().0.to_vec(); - let attrs = parse_attributes(&e); - let comp = handle_self_closing_from_parts(&tag_name, &attrs)?; - components.push(comp); - } - Event::Text(e) => { - let raw = e.xml11_content()?; - let text = unescape(&raw)?; - if !text.is_empty() { - components.push(TextComponent::plain(text.into_owned())); +/// Unescape MiniMessage text: `\<` → `<`, `\\` → `\`. +fn unescape_text(s: &str) -> Cow<'_, str> { + if !s.contains('\\') { + return Cow::Borrowed(s); + } + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('<') => result.push('<'), + Some('\\') => result.push('\\'), + Some(other) => { + result.push('\\'); + result.push(other); } + None => result.push('\\'), } - Event::End(e) => { - if let Some(en) = end_name && e.name().as_ref() == en { - break; + } else { + result.push(c); + } + } + Cow::Owned(result) +} + +/// Split the argument string of a tag (after the first `:`) into individual parameters. +/// Returns borrowed slices when no escape processing was needed. +fn split_args<'a>(input: &'a str, pos: &mut usize, max: usize) -> Vec> { + let mut args = Vec::new(); + while *pos < max { + let start = *pos; + if *pos < max && (input.as_bytes()[*pos] == b'"' || input.as_bytes()[*pos] == b'\'') { + // Quoted argument + let quote = input.as_bytes()[*pos]; + *pos += 1; + let content_start = *pos; + let mut escaped = String::new(); + let mut has_escape = false; + while *pos < max && input.as_bytes()[*pos] != quote { + if input.as_bytes()[*pos] == b'\\' && *pos + 1 < max { + has_escape = true; + if escaped.is_empty() && *pos > content_start { + escaped.push_str(&input[content_start..*pos]); + } + *pos += 1; + match input.as_bytes()[*pos] { + b'\\' => escaped.push('\\'), + b'"' if quote == b'"' => escaped.push('"'), + b'\'' if quote == b'\'' => escaped.push('\''), + c => { + escaped.push('\\'); + escaped.push(c as char); + } + } + } else { + if has_escape { + escaped.push(input.as_bytes()[*pos] as char); + } } + *pos += 1; + } + if *pos < max && input.as_bytes()[*pos] == quote { + *pos += 1; + } + args.push(if has_escape { + Cow::Owned(escaped) + } else { + Cow::Borrowed(&input[content_start..*pos - 1]) + }); + } else { + // Unquoted argument (no escapes allowed) + while *pos < max + && input.as_bytes()[*pos] != b':' + && input.as_bytes()[*pos] != b'>' + { + *pos += 1; } - Event::Eof => break, - _ => {} + args.push(Cow::Borrowed(&input[start..*pos])); + } + if *pos < max && input.as_bytes()[*pos] == b':' { + *pos += 1; + } else { + break; } } - Ok(components) + args } -fn handle_element_from_parts( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: Vec, - attrs: HashMap, -) -> Result { - let name = str::from_utf8(&tag_name) - .unwrap() - .to_lowercase(); - - match name.as_str() { - "lang" | "tr" | "translate" | "lang_or" | "tr_or" | "translate_or" => { - parse_translate_component(reader, buf, &tag_name, &attrs) - } - "selector" | "sel" => parse_selector_component(reader, buf, &tag_name, &attrs), - "score" => parse_score_component(reader, buf, &tag_name, &attrs), - "nbt" | "data" => parse_nbt_component(reader, buf, &tag_name, &attrs), - "sprite" => parse_sprite_component(reader, buf, &tag_name, &attrs), - "head" => parse_head_component(reader, buf, &tag_name, &attrs), - "rainbow" | "gradient" | "transition" | "pride" => { - let children = parse_children(reader, buf, Some(&tag_name))?; - let msg = children - .iter() - .map(|c| c.to_plain(&NoResolutor)) - .collect::(); - Ok(TextComponent::plain(msg)) +/// Parse a named color or hex color (without caching). +fn parse_color(s: &str) -> Option { + parse_color_raw(s) +} + +/// Parse a named color or hex color (without caching). +fn parse_color_raw(s: &str) -> Option { + if let Some(c) = Color::from_hex(s) { + return Some(c); + } + match s { + "black" => Some(Color::Black), + "dark_blue" => Some(Color::DarkBlue), + "dark_green" => Some(Color::DarkGreen), + "dark_aqua" => Some(Color::DarkAqua), + "dark_red" => Some(Color::DarkRed), + "dark_purple" => Some(Color::DarkPurple), + "gold" => Some(Color::Gold), + "gray" | "grey" => Some(Color::Gray), + "dark_gray" | "dark_grey" => Some(Color::DarkGray), + "blue" => Some(Color::Blue), + "green" => Some(Color::Green), + "aqua" => Some(Color::Aqua), + "red" => Some(Color::Red), + "light_purple" => Some(Color::LightPurple), + "yellow" => Some(Color::Yellow), + "white" => Some(Color::White), + _ => None, + } +} + +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(action: &str, value: &str) -> Option { + match action { + "open_url" => Some(ClickEvent::OpenUrl { + url: Cow::Owned(value.to_string()), + }), + "run_command" => Some(ClickEvent::RunCommand { + command: Cow::Owned(value.to_string()), + }), + "suggest_command" => Some(ClickEvent::SuggestCommand { + command: Cow::Owned(value.to_string()), + }), + "change_page" => value + .parse::() + .ok() + .map(|page| ClickEvent::ChangePage { page }), + "copy_to_clipboard" => Some(ClickEvent::CopyToClipboard { + value: Cow::Owned(value.to_string()), + }), + "show_dialog" => Some(ClickEvent::ShowDialog { + dialog: Cow::Owned(value.to_string()), + }), + #[cfg(feature = "custom")] + "custom" => Some(ClickEvent::Custom(CustomData { + id: Cow::Owned(value.to_string()), + payload: Payload::Empty, + })), + _ => None, + } +} + +fn parse_hover(args: &[Cow]) -> Option { + match args.first()?.as_ref() { + "show_text" => { + let text = parse_minimessage(args.get(1)?.as_ref()); + Some(HoverEvent::ShowText { + value: Box::new(text), + }) } - "hover" => { - let mut component = TextComponent::new(); - if !attrs.is_empty() { - component.interactions.hover = - Some(build_hover_from_attrs(&attrs)?); - } - let (hover_event, content) = - parse_hover_content(reader, buf, &tag_name)?; - if let Some(he) = hover_event { - component.interactions.hover = Some(he); - } - component.children = content; - Ok(component) + "show_item" => { + let id = args.get(1)?.to_string(); + let count = args.get(2).and_then(|s| s.parse::().ok()); + let components = args.get(3).map(|s| Cow::Owned(s.to_string())); + Some(HoverEvent::ShowItem { + id: Cow::Owned(id), + count, + components, + }) } - _ => { - let mut component = TextComponent::new(); - apply_format_from_tag(&name, &attrs, &mut component); - apply_interactivity_from_tag(&name, &attrs, &mut component)?; - let children = parse_children(reader, buf, Some(&tag_name))?; - component.children = children; - Ok(component) + "show_entity" => { + let id = args.get(1)?.to_string(); + let uuid = uuid::Uuid::parse_str(args.get(2)?.as_ref()).ok()?; + let name = args.get(3).map(|s| Box::new(parse_minimessage(s.as_ref()))); + Some(HoverEvent::ShowEntity { + name, + id: Cow::Owned(id), + uuid, + }) } + _ => None, } } -fn handle_self_closing_from_parts( - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let name = str::from_utf8(tag_name).unwrap().to_lowercase(); - match name.as_str() { - "reset" => { - let mut comp = TextComponent::new(); - comp.format = Format::new().reset(); - Ok(comp) +/// Parse a MiniMessage string into a component. Useful for nested arguments. +fn parse_minimessage(s: &str) -> TextComponent { + parse(s) +} + +/// Helper function to parse shadow formatting. +fn parse_shadow(args: &[Cow]) -> Format { + let mut format = Format::new(); + if args.is_empty() { + return format; + } + let color_arg = &args[0]; + if let Some(hex) = color_arg.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), + ) { + format.shadow_color = Some(Format::parse_shadow_color(a, r, g, b)); + return format; + } + } else if hex.len() == 6 { + let alpha = args + .get(1) + .and_then(|a| a.parse::().ok()) + .map(|f| (f * 255.0).round() as u8) + .unwrap_or(64); // 0.25 * 255 + 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), + ) { + format.shadow_color = Some(Format::parse_shadow_color(alpha, r, g, b)); + return format; + } } - "br" | "newline" => Ok(TextComponent::plain("\n")), - "keybind" | "key" => { - let key = attrs.get("key").cloned().unwrap_or_default(); - Ok(TextComponent { - content: Content::Keybind { - keybind: Cow::Owned(key), - }, - ..Default::default() - }) + } else if let Some(color) = parse_color(color_arg) { + let (r, g, b) = color_to_rgb(&color); + let alpha = args + .get(1) + .and_then(|a| a.parse::().ok()) + .map(|f| (f * 255.0).round() as u8) + .unwrap_or(64); + format.shadow_color = Some(Format::parse_shadow_color(alpha, r, g, b)); + } + format +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{Content, NbtSource, Object, Resolvable}; + use crate::format::{Color, Format}; + use crate::interactivity::{ClickEvent, HoverEvent}; + + /// Helper to get the first (and often only) child from the implicitly created root component. + fn first_child(comp: &TextComponent) -> &TextComponent { + comp.children.first().expect("expected at least one child") + } + + /// Helper to get all children as a slice. + fn children(comp: &TextComponent) -> &[TextComponent] { + &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); + // The wrapper is empty and has three children: "Hello ", wrapper, "!" + 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); } - _ => { - let mut comp = TextComponent::new(); - apply_format_from_tag(&name, attrs, &mut comp); - apply_interactivity_from_tag(&name, attrs, &mut comp)?; - Ok(comp) + } + + #[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!"); + // Structure: root -> [yellow_wrapper, "world!"] + // yellow_wrapper: empty, color=Yellow, children=[bold_wrapper] + // bold_wrapper: empty, bold=true, children=["Hello "] + 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()); // bold is on the nested wrapper + 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); // red (255,85,85) + default alpha 64 + 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); // aqua (85,255,255), alpha 0.5*255≈128 + 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); } } -} -// ---------- Specific tag parsers ---------- - -fn parse_translate_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let key = attrs.get("key").cloned().unwrap_or_default(); - let fallback = attrs.get("fallback").cloned(); - let mut args: Vec = Vec::new(); - - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::Start(e) if e.name().as_ref() == b"arg" => { - let arg_comps = parse_children(reader, buf, Some(b"arg"))?; - if arg_comps.len() == 1 { - args.push(arg_comps.into_iter().next().unwrap()); - } else if !arg_comps.is_empty() { - args.push(TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children: arg_comps, - format: Format::new(), - interactions: Interactivity::new(), - }); - } + #[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"); } - Event::End(e) if e.name().as_ref() == tag_name => break, - Event::Text(_) => {} // ignore whitespace - Event::Eof => break, - _ => {} + _ => panic!("expected custom click event"), } } - let message = crate::translation::TranslatedMessage { - key: Cow::Owned(key), - fallback: fallback.map(Cow::Owned), - args: if args.is_empty() { - None - } else { - Some(args.into_boxed_slice()) - }, - }; + #[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"), + } + } - Ok(TextComponent { - content: Content::Translate(message), - ..Default::default() - }) -} + #[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())), + }) + ); + } -fn parse_selector_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let selector = attrs.get("selector").cloned().unwrap_or_default(); - let mut separator: Option = None; - - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::Start(e) if e.name().as_ref() == b"separator" => { - let sep_comps = parse_children(reader, buf, Some(b"separator"))?; - separator = Some(if sep_comps.len() == 1 { - sep_comps.into_iter().next().unwrap() - } else { - TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children: sep_comps, - format: Format::new(), - interactions: Interactivity::new(), + #[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") } - }); + ); } - Event::End(e) if e.name().as_ref() == tag_name => break, - Event::Text(_) => {} - Event::Eof => break, - _ => {} + _ => panic!("expected show_entity hover event"), } } - let separator = separator - .map(Box::new) - .unwrap_or_else(Resolvable::entity_separator); - Ok(TextComponent { - content: Content::Resolvable(Resolvable::Entity { - selector: Cow::Owned(selector), - separator, - }), - ..Default::default() - }) -} + #[test] + fn insertion() { + let root = parse("Insert"); + let child = first_child(&root); + assert_eq!( + child.interactions.insertion, + Some(Cow::Owned("test".into())) + ); + } -fn parse_score_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let name = attrs.get("name").cloned().unwrap_or_default(); - let objective = attrs.get("objective").cloned().unwrap_or_default(); - skip_to_end(reader, buf, tag_name)?; - Ok(TextComponent { - content: Content::Resolvable(Resolvable::Scoreboard { - selector: Cow::Owned(name), - objective: Cow::Owned(objective), - }), - ..Default::default() - }) -} + #[test] + fn font() { + let root = parse("Uniform text"); + let child = first_child(&root); + assert_eq!(child.format.font, Some(Cow::Owned("uniform".into()))); + } -fn parse_nbt_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let source_str = attrs.get("source").cloned().unwrap_or_default(); - let id = attrs.get("id").cloned().unwrap_or_default(); - let path = attrs.get("path").cloned().unwrap_or_default(); - let interpret = attrs.get("interpret").map(|_| true); - let mut separator = None; - - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::Start(e) if e.name().as_ref() == b"separator" => { - let sep_comps = parse_children(reader, buf, Some(b"separator"))?; - separator = Some(if sep_comps.len() == 1 { - sep_comps.into_iter().next().unwrap() - } else { - TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children: sep_comps, - format: Format::new(), - interactions: Interactivity::new(), + #[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); + // first arg is a red "1" + 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") + } + ); + // second arg is a blue "Stone" + 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") } - }); + ); } - Event::End(e) if e.name().as_ref() == tag_name => break, - Event::Text(_) => {} - Event::Eof => break, - _ => {} + _ => panic!("expected translation"), } } - let source = match source_str.as_str() { - "block" => NbtSource::Block(Cow::Owned(id)), - "entity" => NbtSource::Entity(Cow::Owned(id)), - "storage" => NbtSource::Storage(Cow::Owned(id)), - _ => { - return Err(MiniMessageError::InvalidTag(format!( - "unknown nbt source: {}", - source_str - ))) + #[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"), } - }; - let separator = separator - .map(Box::new) - .unwrap_or_else(Resolvable::nbt_separator); - Ok(TextComponent { - content: Content::Resolvable(Resolvable::NBT { - path: Cow::Owned(path), - interpret, - separator, - source, - }), - ..Default::default() - }) -} + } -fn parse_sprite_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let atlas = attrs.get("atlas").cloned(); - let sprite = attrs.get("sprite").cloned().unwrap_or_default(); - skip_to_end(reader, buf, tag_name)?; - Ok(TextComponent { - content: Content::Object(Object::Atlas { - atlas: atlas.map(Cow::Owned), - sprite: Cow::Owned(sprite), - }), - ..Default::default() - }) -} + #[test] + fn newline() { + let root = parse("Line1Line2"); + let kids = children(&root); + assert_eq!(kids.len(), 3); // "Line1", newline, "Line2" + assert_eq!( + kids[0].content, + Content::Text { + text: Cow::Borrowed("Line1") + } + ); + assert_eq!( + kids[1].content, + Content::Text { + text: Cow::Borrowed("\n") + } + ); + assert_eq!( + kids[2].content, + Content::Text { + text: Cow::Borrowed("Line2") + } + ); + } -fn parse_head_component( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], - attrs: &HashMap, -) -> Result { - let id = attrs.get("id").cloned().unwrap_or_default(); - let hat = attrs.get("hat").map(|h| h != "false").unwrap_or(true); - skip_to_end(reader, buf, tag_name)?; - - let player = if let Ok(uuid) = Uuid::parse_str(&id) { - let (high, low) = uuid.as_u64_pair(); - let arr = [ - ((high >> 32) & 0xFFFFFFFF) as i32, - (high & 0xFFFFFFFF) as i32, - ((low >> 32) & 0xFFFFFFFF) as i32, - (low & 0xFFFFFFFF) as i32, - ]; - ObjectPlayer { - id: Some(arr), - name: None, - texture: None, - properties: Vec::new(), + #[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"), } - } else if id.contains('/') { - ObjectPlayer::texture(id) - } else { - ObjectPlayer::name(id) - }; - - Ok(TextComponent { - content: Content::Object(Object::Player { player, hat }), - ..Default::default() - }) -} + } -/// Parse the content of a `` element. -fn parse_hover_content( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - parent_tag: &[u8], -) -> Result<(Option, Vec), MiniMessageError> { - let mut hover_event = None; - let mut children = Vec::new(); - - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::Start(e) => { - let sub_name = str::from_utf8(e.name().as_ref()) - .unwrap() - .to_lowercase(); - if sub_name == "value" { - let inner = parse_children(reader, buf, Some(b"value"))?; - let text = if inner.is_empty() { - TextComponent::new() - } else if inner.len() == 1 { - inner.into_iter().next().unwrap() - } else { - TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children: inner, - format: Format::new(), - interactions: Interactivity::new(), - } - }; - hover_event = Some(HoverEvent::ShowText { - value: Box::new(text), - }); - } else if sub_name == "name" { - let inner = parse_children(reader, buf, Some(b"name"))?; - let name = if inner.is_empty() { - None - } else if inner.len() == 1 { - Some(Box::new(inner.into_iter().next().unwrap())) - } else { - Some(Box::new(TextComponent { - content: Content::Text { - text: Cow::Borrowed(""), - }, - children: inner, - format: Format::new(), - interactions: Interactivity::new(), - })) - }; - if let Some(ref mut existing) = hover_event { - if let HoverEvent::ShowEntity { id, uuid, .. } = existing { - *existing = HoverEvent::ShowEntity { - name, - id: id.clone(), - uuid: *uuid, - }; - } - } else { - hover_event = Some(HoverEvent::ShowText { - value: Box::new(TextComponent::new()), - }); + #[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(", ") } - } else { - // treat as normal child – extract name/attrs to avoid borrow conflicts - let tag_name = e.name().0.to_vec(); - let attrs = parse_attributes(&e); - let comp = handle_element_from_parts(reader, buf, tag_name, attrs)?; - children.push(comp); - } + ); } - Event::End(e) if e.name().as_ref() == parent_tag => break, - Event::Text(e) => { - let raw = e.xml11_content()?; - let text = unescape(&raw)?; - if !text.is_empty() { - children.push(TextComponent::plain(text.into_owned())); - } + _ => 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"); } - Event::Eof => break, - _ => {} + _ => panic!("expected scoreboard"), } } - Ok((hover_event, children)) -} + #[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"), + } + } -fn skip_to_end( - reader: &mut Reader<&[u8]>, - buf: &mut Vec, - tag_name: &[u8], -) -> Result<(), MiniMessageError> { - loop { - buf.clear(); - match reader.read_event_into(buf)? { - Event::End(e) if e.name().as_ref() == tag_name => return Ok(()), - Event::Eof => return Ok(()), - _ => {} + #[test] + fn nbt_with_interpret() { + // Use an explicit separator before "interpret" to match the parser's argument layout. + 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"), } } -} -// ---------- Utilities ---------- - -fn parse_attributes(start: &BytesStart) -> HashMap { - let mut map = HashMap::new(); - for attr in start.attributes().flatten() { - let key = str::from_utf8(attr.key.as_ref()) - .unwrap() - .to_lowercase(); - let value = attr - .decode_and_unescape_value(start.decoder()) - .unwrap_or_default() - .into_owned(); - map.insert(key, value); - } - map -} + #[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"), + } + } -fn apply_format_from_tag(name: &str, attrs: &HashMap, comp: &mut TextComponent) { - match name { - "color" | "c" | "colour" => { - if let Some(col) = attrs.get("name") - && let Some(color) = Color::from_hex(col).or_else(|| Color::from_name(col)) { - comp.format.color = Some(color); + #[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"), } - "shadow" => { - if let Some(col) = attrs.get("color") { - let alpha = attrs - .get("alpha") - .and_then(|a| a.parse::().ok()) - .unwrap_or(0.25); - if let Some(color) = parse_color_with_alpha(col, alpha) { - comp.format.shadow_color = Some(color); - } + } + + #[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"), } - "bold" | "b" => comp.format.bold = Some(true), - "italic" | "em" | "i" => comp.format.italic = Some(true), - "underlined" | "u" => comp.format.underlined = Some(true), - "strikethrough" | "st" => comp.format.strikethrough = Some(true), - "obfuscated" | "obf" => comp.format.obfuscated = Some(true), - "font" => { - if let Some(f) = attrs.get("font") { - comp.format.font = Some(Cow::Owned(f.clone())); + } + + #[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"), } - _ => {} } -} -fn parse_color_with_alpha(color_str: &str, alpha: f32) -> Option { - let c = Color::from_hex(color_str)?; - if let Color::Rgb(r, g, b) = c { - let a = (alpha * 255.0) as u8; - Some(Format::parse_shadow_color(a, r, g, b)) - } else { - None + #[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"), + } } -} -fn apply_interactivity_from_tag( - name: &str, - attrs: &HashMap, - comp: &mut TextComponent, -) -> Result<(), MiniMessageError> { - match name { - "click" => { - let action = attrs.get("action").cloned().unwrap_or_default(); - let value = attrs.get("value").cloned().unwrap_or_default(); - let event = match action.as_str() { - "open_url" => ClickEvent::OpenUrl { - url: Cow::Owned(value), - }, - "run_command" => ClickEvent::RunCommand { - command: Cow::Owned(value), - }, - "suggest_command" => ClickEvent::SuggestCommand { - command: Cow::Owned(value), - }, - "change_page" => { - let page = value.parse::().unwrap_or(1); - ClickEvent::ChangePage { page } - } - "copy_to_clipboard" => ClickEvent::CopyToClipboard { - value: Cow::Owned(value), - }, - "show_dialog" => ClickEvent::ShowDialog { - dialog: Cow::Owned(value), - }, - _ => { - return Err(MiniMessageError::InvalidTag(format!( - "click action: {}", - action - ))) - } - }; - comp.interactions.click = Some(event); + #[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"), } - "insertion" | "insert" => { - let val = attrs.get("value").cloned().unwrap_or_default(); - comp.interactions.insertion = Some(Cow::Owned(val)); + } + + #[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"), } - _ => {} } - Ok(()) -} -fn build_hover_from_attrs( - attrs: &HashMap, -) -> Result { - let action = attrs.get("action").cloned().unwrap_or_default(); - match action.as_str() { - "show_item" => { - let id = attrs.get("item").cloned().unwrap_or_default(); - let count = attrs.get("count").and_then(|c| c.parse().ok()); - let components = attrs.get("tag").cloned(); - Ok(HoverEvent::ShowItem { - id: Cow::Owned(id), - count, - components: components.map(Cow::Owned), - }) + #[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"), } - "show_entity" => { - let etype = attrs.get("type").cloned().unwrap_or_default(); - let uuid_str = attrs.get("uuid").cloned().unwrap_or_default(); - let uuid = Uuid::parse_str(&uuid_str).unwrap_or(Uuid::nil()); - Ok(HoverEvent::ShowEntity { - name: None, - id: Cow::Owned(etype), - uuid, - }) + } + + #[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"), } - _ => Err(MiniMessageError::InvalidTag(format!( - "hover action: {}", - action - ))), } -} -impl Color { - pub fn from_name(name: &str) -> Option { - match name { - "black" => Some(Color::Black), - "dark_blue" => Some(Color::DarkBlue), - "dark_green" => Some(Color::DarkGreen), - "dark_aqua" => Some(Color::DarkAqua), - "dark_red" => Some(Color::DarkRed), - "dark_purple" => Some(Color::DarkPurple), - "gold" => Some(Color::Gold), - "gray" | "grey" => Some(Color::Gray), - "dark_gray" | "dark_grey" => Some(Color::DarkGray), - "blue" => Some(Color::Blue), - "green" => Some(Color::Green), - "aqua" => Some(Color::Aqua), - "red" => Some(Color::Red), - "light_purple" => Some(Color::LightPurple), - "yellow" => Some(Color::Yellow), - "white" => Some(Color::White), - _ => None, + #[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); + // first child is the escaped backslash + assert_eq!( + kids[0].content, + Content::Text { + text: Cow::Owned("\\".into()) + } + ); + // second child is the wrapper containing "test" + 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() { + // backslash-escape the quote inside the string + 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"), } } -} \ No newline at end of file +} From e0f16dea8bb9232ecf4aca7e9950838f709ddf35 Mon Sep 17 00:00:00 2001 From: suprohub Date: Mon, 4 May 2026 20:42:39 +0300 Subject: [PATCH 03/15] Micro optimizato --- Cargo.lock | 8 + Cargo.toml | 2 + src/minimessage.rs | 691 ++++++++++++++++++++++----------------------- 3 files changed, 351 insertions(+), 350 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 66c0d13..21f8b2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,6 +502,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "supports-hyperlinks" version = "3.2.0" @@ -526,6 +532,7 @@ dependencies = [ "chrono", "colored", "heck", + "memchr", "proc-macro2", "quote", "rand", @@ -533,6 +540,7 @@ dependencies = [ "serde", "serde_json", "simdnbt", + "smallvec", "supports-hyperlinks", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 56c6392..2784058 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,8 @@ 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" +memchr = "2.8" +smallvec = "1.15" [dev-dependencies] chrono = "0.4" diff --git a/src/minimessage.rs b/src/minimessage.rs index d1a6a71..e4eb94a 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -1,4 +1,4 @@ -// minimessage.rs +use smallvec::SmallVec; use crate::{ TextComponent, @@ -24,11 +24,8 @@ fn new_component(content: Content) -> TextComponent { } struct Parser { - /// All created nodes. Node 0 is the implicit root. nodes: Vec, - /// children[i] contains indices of direct children of node i. children: Vec>, - /// Stack of currently open wrapper tags: (node_index, tag_name_lowercase). stack: Vec<(usize, String)>, } @@ -40,37 +37,42 @@ impl Parser { stack: vec![(0, String::new())], }; let len = input.len(); + let bytes = input.as_bytes(); let mut i = 0; while i < len { - // 1. Collect plain text until '<' let start = i; - while i < len && input.as_bytes()[i] != b'<' { + if let Some(offset) = memchr::memchr(b'<', &bytes[i..]) { + i += offset; + if i > start { + let text = unescape_text(&input[start..i]); + if !text.is_empty() { + let comp = TextComponent::plain(text.into_owned()); + let parent = parser.stack.last().unwrap().0; + parser.add_child_node(parent, comp); + } + } i += 1; - } - if i > start { - let text = unescape_text(&input[start..i]); - if !text.is_empty() { - let comp = TextComponent::plain(text.into_owned()); - let parent = parser.stack.last().unwrap().0; - parser.add_child_node(parent, comp); + } else { + if i < len { + let text = unescape_text(&input[i..]); + if !text.is_empty() { + let comp = TextComponent::plain(text.into_owned()); + let parent = parser.stack.last().unwrap().0; + parser.add_child_node(parent, comp); + } } - } - if i >= len { break; } - // 2. Skip '<' - i += 1; if i >= len { break; } - // 3. Closing tag? - if input.as_bytes()[i] == b'/' { + if bytes[i] == b'/' { i += 1; let end = i; - while i < len && input.as_bytes()[i] != b'>' { + while i < len && bytes[i] != b'>' { i += 1; } let tag_name = &input[end..i]; @@ -81,31 +83,29 @@ impl Parser { continue; } - // 4. Opening or self-closing tag let tag_start = i; while i < len { - match input.as_bytes()[i] { + match bytes[i] { b'>' | b':' | b'/' => break, _ => i += 1, } } let tag_name = &input[tag_start..i]; - let mut args = Vec::new(); + let mut args = SmallVec::new(); let mut self_closing = false; - if i < len && input.as_bytes()[i] == b':' { + if i < len && bytes[i] == b':' { i += 1; args = split_args(input, &mut i, len); } - if i < len && input.as_bytes()[i] == b'/' { + if i < len && bytes[i] == b'/' { self_closing = true; i += 1; } - if i < len && input.as_bytes()[i] == b'>' { + if i < len && bytes[i] == b'>' { i += 1; } else { - // skip invalid - while i < len && input.as_bytes()[i] != b'>' { + while i < len && bytes[i] != b'>' { i += 1; } if i < len { @@ -119,8 +119,22 @@ impl Parser { parser.finish() } - /// Add a child component to `parent`, return the new node index. fn add_child_node(&mut self, parent: usize, child: TextComponent) -> 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()); @@ -128,29 +142,21 @@ impl Parser { idx } - /// Push a wrapper: add child component and optionally push its index + tag name onto the stack. fn push_tag_to_stack( &mut self, parent: usize, comp: TextComponent, - tag_name: String, // already lowercased, owned + tag_name: Option, self_closing: bool, ) -> usize { let idx = self.add_child_node(parent, comp); - if !self_closing { - self.stack.push((idx, tag_name)); + if !self_closing && let Some(name) = tag_name { + self.stack.push((idx, name)); } idx } - /// Convenience: push a formatting-only wrapper (with empty text content). - fn push_format_wrapper( - &mut self, - parent: usize, - format: Format, - tag_name: String, - self_closing: bool, - ) { + fn push_format_wrapper(&mut self, parent: usize, format: Format, self_closing: bool) -> usize { let comp = TextComponent { content: Content::Text { text: Cow::Borrowed(""), @@ -158,177 +164,106 @@ impl Parser { format, ..Default::default() }; - self.push_tag_to_stack(parent, comp, tag_name, self_closing); + self.push_tag_to_stack(parent, comp, None, self_closing) } - /// Close the nearest open tag with the given name. fn close_tag(&mut self, tag_name: &str) { let tag = tag_name.to_lowercase(); if tag == "reset" { - return; // reset cannot be closed + return; } if let Some(pos) = self.stack.iter().rposition(|(_, name)| *name == tag) { self.stack.truncate(pos); } } - /// Entry point for any opening or self-closing tag. - fn process_open_tag(&mut self, tag_name: &str, args: Vec>, self_closing: bool) { + fn process_open_tag( + &mut self, + tag_name: &str, + args: SmallVec<[Cow<'_, str>; 4]>, + self_closing: bool, + ) { let parent = self.stack.last().map(|s| s.0).unwrap_or(0); let tag_lower = tag_name.to_lowercase(); match tag_lower.as_str() { - // Decorations "b" | "bold" | "!b" | "!bold" | "i" | "em" | "italic" | "!i" | "!em" | "!italic" | "u" | "underlined" | "!u" | "!underlined" | "st" | "strikethrough" | "!st" | "!strikethrough" | "obf" | "obfuscated" | "!obf" | "!obfuscated" => { - self.handle_decoration_tag(&tag_lower, parent, self_closing); - } - - // Reset - "reset" => { - self.stack.truncate(1); - // reset itself is never pushed onto the stack - } - - // Shadow - "shadow" => { - self.handle_shadow_tag(args, parent, self_closing); + self.handle_decoration_tag(&tag_lower, parent, self_closing) } + "reset" => self.stack.truncate(1), + "shadow" => self.handle_shadow_tag(args, parent, self_closing), "!shadow" => { - // Fully transparent shadow let mut fmt = Format::new(); fmt.shadow_color = Some(0); - self.push_format_wrapper(parent, fmt, tag_lower, self_closing); + let idx = self.push_format_wrapper(parent, fmt, self_closing); + if !self_closing { + self.stack.push((idx, "!shadow".to_string())); + } } - - // Verbose color "color" | "c" | "colour" => { - self.handle_verbose_color_tag(tag_lower, args, parent, self_closing); - } - - // Click - "click" => { - self.handle_click_tag(args, parent, self_closing); - } - - // Hover - "hover" => { - self.handle_hover_tag(args, parent, self_closing); - } - - // Insertion - "insert" => { - self.handle_insertion_tag(args, parent, self_closing); - } - - // Font - "font" => { - self.handle_font_tag(args, parent, self_closing); - } - - // Keybind - "key" => { - self.handle_keybind_tag(args, parent); - } - - // Translatable - "lang" | "tr" | "translate" => { - self.handle_translate_tag(args, parent, None); - } + self.handle_verbose_color_tag(tag_lower, args, parent, self_closing) + } + "click" => self.handle_click_tag(args, parent, self_closing), + "hover" => self.handle_hover_tag(args, parent, self_closing), + "insert" => self.handle_insertion_tag(args, parent, self_closing), + "font" => self.handle_font_tag(args, parent, self_closing), + "key" => self.handle_keybind_tag(args, parent), + "lang" | "tr" | "translate" => self.handle_translate_tag(args, parent, None), "lang_or" | "tr_or" | "translate_or" => { - self.handle_translate_tag(args, parent, Some(true)); + self.handle_translate_tag(args, parent, Some(true)) } - - // Newline "newline" | "br" => { self.add_child_node(parent, TextComponent::plain("\n")); } - - // Selector - "selector" | "sel" => { - self.handle_selector_tag(args, parent); - } - - // Score - "score" => { - self.handle_score_tag(args, parent); - } - - // NBT - "nbt" | "data" => { - self.handle_nbt_tag(args, parent); - } - - // Sprite - "sprite" => { - self.handle_sprite_tag(args, parent); - } - - // Head - "head" => { - self.handle_head_tag(args, parent); - } - - // Custom elements (only when feature "custom" is enabled) + "selector" | "sel" => self.handle_selector_tag(args, parent), + "score" => self.handle_score_tag(args, parent), + "nbt" | "data" => self.handle_nbt_tag(args, parent), + "sprite" => self.handle_sprite_tag(args, parent), + "head" => self.handle_head_tag(args, parent), #[cfg(feature = "custom")] - "rainbow" => { - // TODO: actually parse phase and direction arguments - let comp = new_component(Content::Custom(CustomData { - id: Cow::Borrowed("rainbow"), - payload: Payload::Empty, - })); - self.push_tag_to_stack(parent, comp, tag_lower, self_closing); - } + "rainbow" => self.push_custom_tag(parent, "rainbow", tag_lower, 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, tag_lower, self_closing); - } + "gradient" => self.push_custom_tag(parent, "gradient", tag_lower, 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, tag_lower, self_closing); - } + "transition" => self.push_custom_tag(parent, "transition", tag_lower, 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, tag_lower, self_closing); - } - - // Fallback: treat as a colour name or #hex code + "pride" => self.push_custom_tag(parent, "pride", tag_lower, self_closing), _ => { - if let Some(color) = parse_color(&tag_lower) { - self.push_format_wrapper( - parent, - Format::new().color(color), - tag_lower, - self_closing, - ); - } else if tag_lower.starts_with('#') - && let Some(color) = Color::from_hex(&tag_lower) - { - self.push_format_wrapper( - parent, - Format::new().color(color), - tag_lower, - self_closing, - ); + let color = parse_color(&tag_lower).or_else(|| { + if tag_lower.starts_with('#') { + Color::from_hex(&tag_lower) + } else { + None + } + }); + + if let Some(color) = color { + let idx = + self.push_format_wrapper(parent, Format::new().color(color), self_closing); + if !self_closing { + self.stack.push((idx, tag_lower.to_string())); + } } - // otherwise unknown tag → silently ignore (lenient mode) } } } + #[cfg(feature = "custom")] + fn push_custom_tag(&mut self, parent: usize, id: &str, tag: String, self_closing: bool) { + let comp = new_component(Content::Custom(CustomData { + id: Cow::Borrowed(id), + payload: Payload::Empty, + })); + self.push_tag_to_stack( + parent, + comp, + if self_closing { None } else { Some(tag) }, + self_closing, + ); + } + fn handle_decoration_tag(&mut self, tag: &str, parent: usize, self_closing: bool) { let (decoration, value) = if let Some(rest) = tag.strip_prefix('!') { (rest, false) @@ -336,28 +271,39 @@ impl Parser { (tag, true) }; + let f = Format::new(); let format = match decoration { - "b" | "bold" => Format::new().bold(value), - "i" | "em" | "italic" => Format::new().italic(value), - "u" | "underlined" => Format::new().underlined(value), - "st" | "strikethrough" => Format::new().strikethrough(value), - "obf" | "obfuscated" => Format::new().obfuscated(value), + "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, }; - // tag is already lowercased; we pass it as String (cloned) because we need ownership. - self.push_format_wrapper(parent, format, tag.to_string(), self_closing); + let idx = self.push_format_wrapper(parent, format, self_closing); + if !self_closing { + self.stack.push((idx, tag.to_string())); + } } - fn handle_shadow_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + fn handle_shadow_tag( + &mut self, + args: SmallVec<[Cow<'_, str>; 4]>, + parent: usize, + self_closing: bool, + ) { let format = parse_shadow(&args); - self.push_format_wrapper(parent, format, "shadow".to_string(), self_closing); + let idx = self.push_format_wrapper(parent, format, self_closing); + if !self_closing { + self.stack.push((idx, "shadow".to_string())); + } } fn handle_verbose_color_tag( &mut self, tag: String, - args: Vec>, + args: SmallVec<[Cow<'_, str>; 4]>, parent: usize, self_closing: bool, ) { @@ -366,72 +312,108 @@ impl Parser { Some(c) => Format::new().color(c), None => Format::new(), }; - self.push_format_wrapper(parent, format, tag, self_closing); + let idx = self.push_format_wrapper(parent, format, self_closing); + if !self_closing { + self.stack.push((idx, tag)); + } } - fn handle_click_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + fn handle_click_tag( + &mut self, + args: SmallVec<[Cow<'_, str>; 4]>, + parent: usize, + self_closing: bool, + ) { if args.len() >= 2 { - let action = take_arg(&args[0]); - let value: String = args[1..] - .iter() - .map(|a| a.as_ref()) - .collect::>() - .join(":"); + let action = take_arg(args[0].clone()); + let value: String = args[1..].iter().fold(String::new(), |mut acc, a| { + if !acc.is_empty() { + acc.push(':'); + } + acc.push_str(a.as_ref()); + acc + }); let click = parse_click(&action, &value); let comp = new_component(Content::Text { text: Cow::Borrowed(""), }); let mut comp = comp; comp.interactions.click = click; - self.push_tag_to_stack(parent, comp, "click".to_string(), self_closing); + let tag_name = if self_closing { + None + } else { + Some("click".to_string()) + }; + self.push_tag_to_stack(parent, comp, tag_name, self_closing); } } - fn handle_hover_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + fn handle_hover_tag( + &mut self, + args: SmallVec<[Cow<'_, str>; 4]>, + parent: usize, + self_closing: bool, + ) { 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, "hover".to_string(), self_closing); + let tag_name = if self_closing { + None + } else { + Some("hover".to_string()) + }; + self.push_tag_to_stack(parent, comp, tag_name, self_closing); } - fn handle_insertion_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { + fn handle_insertion_tag( + &mut self, + args: SmallVec<[Cow<'_, str>; 4]>, + parent: usize, + self_closing: bool, + ) { if let Some(text) = args.first() { let mut comp = new_component(Content::Text { text: Cow::Borrowed(""), }); - comp.interactions.insertion = Some(Cow::Owned(take_arg(text))); - self.push_tag_to_stack(parent, comp, "insert".to_string(), self_closing); + comp.interactions.insertion = Some(Cow::Owned(take_arg(text.clone()))); + let tag_name = if self_closing { + None + } else { + Some("insert".to_string()) + }; + self.push_tag_to_stack(parent, comp, tag_name, self_closing); } } - fn handle_font_tag(&mut self, args: Vec>, parent: usize, self_closing: bool) { - let font = args - .into_iter() - .map(|a| match a { - Cow::Owned(s) => s, - Cow::Borrowed(s) => s.to_string(), - }) - .collect::>() - .join(":"); - self.push_format_wrapper( - parent, - Format::new().font(font), - "font".to_string(), - self_closing, - ); + fn handle_font_tag( + &mut self, + args: SmallVec<[Cow<'_, str>; 4]>, + parent: usize, + self_closing: bool, + ) { + let font = args.into_iter().fold(String::new(), |mut acc, a| { + if !acc.is_empty() { + acc.push(':'); + } + acc.push_str(a.as_ref()); + acc + }); + let idx = self.push_format_wrapper(parent, Format::new().font(font), self_closing); + if !self_closing { + self.stack.push((idx, "font".to_string())); + } } - fn handle_keybind_tag(&mut self, args: Vec>, parent: usize) { - let keybind = args - .into_iter() - .map(|a| match a { - Cow::Owned(s) => s, - Cow::Borrowed(s) => s.to_string(), - }) - .collect::>() - .join(":"); + fn handle_keybind_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { + let keybind = args.into_iter().fold(String::new(), |mut acc, a| { + if !acc.is_empty() { + acc.push(':'); + } + acc.push_str(a.as_ref()); + acc + }); let comp = new_component(Content::Keybind { keybind: Cow::Owned(keybind), }); @@ -440,7 +422,7 @@ impl Parser { fn handle_translate_tag( &mut self, - args: Vec>, + args: SmallVec<[Cow<'_, str>; 4]>, parent: usize, has_fallback: Option, ) { @@ -473,7 +455,7 @@ impl Parser { } } - fn handle_selector_tag(&mut self, args: Vec>, parent: usize) { + fn handle_selector_tag(&mut self, args: SmallVec<[Cow<'_, 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) { @@ -490,7 +472,7 @@ impl Parser { } } - fn handle_score_tag(&mut self, args: Vec>, parent: usize) { + fn handle_score_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { let mut args = args; if let (Some(name), Some(objective)) = (take_first_arg(&mut args), take_first_arg(&mut args)) @@ -504,14 +486,14 @@ impl Parser { } } - fn handle_nbt_tag(&mut self, args: Vec>, parent: usize) { + fn handle_nbt_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { let args = args; if args.len() >= 3 { - let source_type = take_arg(&args[0]); - let id = take_arg(&args[1]); - let path = take_arg(&args[2]); + let source_type = take_arg(args[0].clone()); + let id = take_arg(args[1].clone()); + let path = take_arg(args[2].clone()); let separator = if args.get(3).is_some() { - let sep = take_arg(&args[3]); + let sep = take_arg(args[3].clone()); Box::new(parse_minimessage(&sep)) } else { Resolvable::nbt_separator() @@ -534,7 +516,7 @@ impl Parser { } } - fn handle_sprite_tag(&mut self, args: Vec>, parent: usize) { + fn handle_sprite_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { let mut args = args; let (atlas, sprite) = if args.len() == 1 { (None, take_first_arg(&mut args).unwrap_or_default()) @@ -552,7 +534,7 @@ impl Parser { self.add_child_node(parent, comp); } - fn handle_head_tag(&mut self, args: Vec>, parent: usize) { + fn handle_head_tag(&mut self, args: SmallVec<[Cow<'_, 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"); @@ -579,7 +561,6 @@ impl Parser { } fn finish(mut self) -> TextComponent { - // close any remaining open wrappers (root is always at 0) self.stack.truncate(1); self.build_node(0) } @@ -595,16 +576,14 @@ impl Parser { } } -/// Extract an owned String from a Cow, cloning only when necessary. -fn take_arg(cow: &Cow) -> String { +fn take_arg(cow: Cow<'_, str>) -> String { match cow { Cow::Borrowed(s) => (*s).to_string(), Cow::Owned(s) => s.clone(), } } -/// Remove the first element of a Vec> and convert it to an owned String. -fn take_first_arg(args: &mut Vec>) -> Option { +fn take_first_arg(args: &mut SmallVec<[Cow<'_, str>; 4]>) -> Option { if args.is_empty() { return None; } @@ -615,123 +594,161 @@ fn take_first_arg(args: &mut Vec>) -> Option { }) } -/// Unescape MiniMessage text: `\<` → `<`, `\\` → `\`. fn unescape_text(s: &str) -> Cow<'_, str> { if !s.contains('\\') { return Cow::Borrowed(s); } + let bytes = s.as_bytes(); let mut result = String::with_capacity(s.len()); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\\' { - match chars.next() { - Some('<') => result.push('<'), - Some('\\') => result.push('\\'), - Some(other) => { + 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); + result.push(other as char); + start += 1; } - None => result.push('\\'), } } else { - result.push(c); + result.push('\\'); + break; } } + if start < s.len() { + result.push_str(&s[start..]); + } Cow::Owned(result) } -/// Split the argument string of a tag (after the first `:`) into individual parameters. -/// Returns borrowed slices when no escape processing was needed. -fn split_args<'a>(input: &'a str, pos: &mut usize, max: usize) -> Vec> { - let mut args = Vec::new(); +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 *pos < max && (input.as_bytes()[*pos] == b'"' || input.as_bytes()[*pos] == b'\'') { - // Quoted argument - let quote = input.as_bytes()[*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; - while *pos < max && input.as_bytes()[*pos] != quote { - if input.as_bytes()[*pos] == b'\\' && *pos + 1 < max { - has_escape = true; - if escaped.is_empty() && *pos > content_start { - escaped.push_str(&input[content_start..*pos]); - } - *pos += 1; - match input.as_bytes()[*pos] { - b'\\' => escaped.push('\\'), - b'"' if quote == b'"' => escaped.push('"'), - b'\'' if quote == b'\'' => escaped.push('\''), - c => { - escaped.push('\\'); - escaped.push(c as char); + 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; } - } else { - if has_escape { - escaped.push(input.as_bytes()[*pos] as char); + 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; + } + } } } - *pos += 1; } - if *pos < max && input.as_bytes()[*pos] == quote { + if *pos < max && bytes[*pos] == b':' { *pos += 1; - } - args.push(if has_escape { - Cow::Owned(escaped) + continue; } else { - Cow::Borrowed(&input[content_start..*pos - 1]) - }); - } else { - // Unquoted argument (no escapes allowed) - while *pos < max - && input.as_bytes()[*pos] != b':' - && input.as_bytes()[*pos] != b'>' - { - *pos += 1; + break; } - args.push(Cow::Borrowed(&input[start..*pos])); - } - if *pos < max && input.as_bytes()[*pos] == b':' { - *pos += 1; } else { - break; + 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 } -/// Parse a named color or hex color (without caching). fn parse_color(s: &str) -> Option { - parse_color_raw(s) -} - -/// Parse a named color or hex color (without caching). -fn parse_color_raw(s: &str) -> Option { - if let Some(c) = Color::from_hex(s) { - return Some(c); - } - match s { - "black" => Some(Color::Black), - "dark_blue" => Some(Color::DarkBlue), - "dark_green" => Some(Color::DarkGreen), - "dark_aqua" => Some(Color::DarkAqua), - "dark_red" => Some(Color::DarkRed), - "dark_purple" => Some(Color::DarkPurple), - "gold" => Some(Color::Gold), - "gray" | "grey" => Some(Color::Gray), - "dark_gray" | "dark_grey" => Some(Color::DarkGray), - "blue" => Some(Color::Blue), - "green" => Some(Color::Green), - "aqua" => Some(Color::Aqua), - "red" => Some(Color::Red), - "light_purple" => Some(Color::LightPurple), - "yellow" => Some(Color::Yellow), - "white" => Some(Color::White), - _ => None, - } + 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) { @@ -818,12 +835,10 @@ fn parse_hover(args: &[Cow]) -> Option { } } -/// Parse a MiniMessage string into a component. Useful for nested arguments. fn parse_minimessage(s: &str) -> TextComponent { parse(s) } -/// Helper function to parse shadow formatting. fn parse_shadow(args: &[Cow]) -> Format { let mut format = Format::new(); if args.is_empty() { @@ -846,7 +861,7 @@ fn parse_shadow(args: &[Cow]) -> Format { .get(1) .and_then(|a| a.parse::().ok()) .map(|f| (f * 255.0).round() as u8) - .unwrap_or(64); // 0.25 * 255 + .unwrap_or(64); if let (Ok(r), Ok(g), Ok(b)) = ( u8::from_str_radix(&hex[0..2], 16), u8::from_str_radix(&hex[2..4], 16), @@ -875,12 +890,10 @@ mod tests { use crate::format::{Color, Format}; use crate::interactivity::{ClickEvent, HoverEvent}; - /// Helper to get the first (and often only) child from the implicitly created root component. fn first_child(comp: &TextComponent) -> &TextComponent { comp.children.first().expect("expected at least one child") } - /// Helper to get all children as a slice. fn children(comp: &TextComponent) -> &[TextComponent] { &comp.children } @@ -925,7 +938,6 @@ mod tests { fn color_nested() { let root = parse("Hello World!"); let top_child = first_child(&root); - // The wrapper is empty and has three children: "Hello ", wrapper, "!" assert_eq!( top_child.content, Content::Text { @@ -1017,15 +1029,12 @@ mod tests { #[test] fn reset_clears_style() { let root = parse("Hello world!"); - // Structure: root -> [yellow_wrapper, "world!"] - // yellow_wrapper: empty, color=Yellow, children=[bold_wrapper] - // bold_wrapper: empty, bold=true, children=["Hello "] 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()); // bold is on the nested wrapper + assert!(yellow.format.bold.is_none()); assert_eq!(yellow.children.len(), 1); let bold = &yellow.children[0]; @@ -1053,7 +1062,7 @@ mod tests { fn shadow_named() { let root = parse("Shadow"); let child = first_child(&root); - let expected = Format::parse_shadow_color(64, 255, 85, 85); // red (255,85,85) + default alpha 64 + let expected = Format::parse_shadow_color(64, 255, 85, 85); assert_eq!(child.format.shadow_color, Some(expected)); } @@ -1061,7 +1070,7 @@ mod tests { fn shadow_alpha() { let root = parse("Test"); let child = first_child(&root); - let expected = Format::parse_shadow_color(128, 85, 255, 255); // aqua (85,255,255), alpha 0.5*255≈128 + let expected = Format::parse_shadow_color(128, 85, 255, 255); assert_eq!(child.format.shadow_color, Some(expected)); } @@ -1298,7 +1307,6 @@ mod tests { assert_eq!(msg.key, "commands.drop.success.single"); let args = msg.args.as_ref().unwrap(); assert_eq!(args.len(), 2); - // first arg is a red "1" let arg1 = &args[0]; let red_child = arg1.children.first().unwrap(); assert_eq!(red_child.format.color, Some(Color::Red)); @@ -1308,7 +1316,6 @@ mod tests { text: Cow::Borrowed("1") } ); - // second arg is a blue "Stone" let arg2 = &args[1]; let blue_child = arg2.children.first().unwrap(); assert_eq!(blue_child.format.color, Some(Color::Blue)); @@ -1341,23 +1348,11 @@ mod tests { fn newline() { let root = parse("Line1Line2"); let kids = children(&root); - assert_eq!(kids.len(), 3); // "Line1", newline, "Line2" + assert_eq!(kids.len(), 1); assert_eq!( kids[0].content, Content::Text { - text: Cow::Borrowed("Line1") - } - ); - assert_eq!( - kids[1].content, - Content::Text { - text: Cow::Borrowed("\n") - } - ); - assert_eq!( - kids[2].content, - Content::Text { - text: Cow::Borrowed("Line2") + text: Cow::Borrowed("Line1\nLine2") } ); } @@ -1436,7 +1431,6 @@ mod tests { #[test] fn nbt_with_interpret() { - // Use an explicit separator before "interpret" to match the parser's argument layout. let root = parse(""); let child = first_child(&root); match &child.content { @@ -1617,14 +1611,12 @@ mod tests { let root = parse(r"\\test"); let kids = children(&root); assert_eq!(kids.len(), 2); - // first child is the escaped backslash assert_eq!( kids[0].content, Content::Text { text: Cow::Owned("\\".into()) } ); - // second child is the wrapper containing "test" let red_wrapper = &kids[1]; assert_eq!(red_wrapper.format.color, Some(Color::Red)); assert_eq!(red_wrapper.children.len(), 1); @@ -1666,7 +1658,6 @@ mod tests { #[test] fn quoted_args_with_escaped_quote() { - // backslash-escape the quote inside the string let root = parse(r"Hover"); let child = first_child(&root); match &child.interactions.hover { From 0afa7e01e1a38884556e99709bc670644798309d Mon Sep 17 00:00:00 2001 From: suprohub Date: Mon, 4 May 2026 21:58:34 +0300 Subject: [PATCH 04/15] Reformat code --- src/lib.rs | 2 + src/minimessage.rs | 1469 ++++++++------------------------------ src/minimessage_tests.rs | 793 ++++++++++++++++++++ 3 files changed, 1106 insertions(+), 1158 deletions(-) create mode 100644 src/minimessage_tests.rs diff --git a/src/lib.rs b/src/lib.rs index 9f236a5..7960a6b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,8 @@ pub mod fmt; pub mod format; pub mod interactivity; pub mod minimessage; +#[cfg(test)] +mod minimessage_tests; #[cfg(feature = "nbt")] pub mod nbt; pub mod parse; diff --git a/src/minimessage.rs b/src/minimessage.rs index e4eb94a..58b1070 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -23,18 +23,29 @@ fn new_component(content: Content) -> TextComponent { } } -struct Parser { +fn join_with_colon(args: &[Cow]) -> String { + let mut result = String::new(); + for a in args { + if !result.is_empty() { + result.push(':'); + } + result.push_str(a.as_ref()); + } + result +} + +struct Parser<'a> { nodes: Vec, children: Vec>, - stack: Vec<(usize, String)>, + stack: Vec<(usize, Cow<'a, str>)>, } -impl Parser { - fn parse(input: &str) -> TextComponent { +impl<'a> Parser<'a> { + fn parse(input: &'a str) -> TextComponent { let mut parser = Parser { nodes: vec![TextComponent::new()], children: vec![Vec::new()], - stack: vec![(0, String::new())], + stack: vec![(0, Cow::Borrowed(""))], }; let len = input.len(); let bytes = input.as_bytes(); @@ -42,33 +53,27 @@ impl Parser { while i < len { let start = i; - if let Some(offset) = memchr::memchr(b'<', &bytes[i..]) { + let next_tag = memchr::memchr(b'<', &bytes[i..]); + if let Some(offset) = next_tag { i += offset; - if i > start { - let text = unescape_text(&input[start..i]); - if !text.is_empty() { - let comp = TextComponent::plain(text.into_owned()); - let parent = parser.stack.last().unwrap().0; - parser.add_child_node(parent, comp); - } - } - i += 1; } else { - if i < len { - let text = unescape_text(&input[i..]); - if !text.is_empty() { - let comp = TextComponent::plain(text.into_owned()); - let parent = parser.stack.last().unwrap().0; - parser.add_child_node(parent, comp); - } + i = len; + } + if i > start { + let text = unescape_text(&input[start..i]); + if !text.is_empty() { + let comp = TextComponent::plain(text.into_owned()); + 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; @@ -82,7 +87,6 @@ impl Parser { parser.close_tag(tag_name); continue; } - let tag_start = i; while i < len { match bytes[i] { @@ -146,7 +150,7 @@ impl Parser { &mut self, parent: usize, comp: TextComponent, - tag_name: Option, + tag_name: Option>, self_closing: bool, ) -> usize { let idx = self.add_child_node(parent, comp); @@ -156,7 +160,13 @@ impl Parser { idx } - fn push_format_wrapper(&mut self, parent: usize, format: Format, self_closing: bool) -> usize { + fn push_format_tag( + &mut self, + parent: usize, + format: Format, + self_closing: bool, + tag: &'a str, + ) -> usize { let comp = TextComponent { content: Content::Text { text: Cow::Borrowed(""), @@ -164,276 +174,241 @@ impl Parser { format, ..Default::default() }; - self.push_tag_to_stack(parent, comp, None, self_closing) + self.push_tag_to_stack(parent, comp, Some(Cow::Borrowed(tag)), self_closing) } fn close_tag(&mut self, tag_name: &str) { - let tag = tag_name.to_lowercase(); - if tag == "reset" { + let tag_lower = tag_name.to_lowercase(); + if tag_lower == "reset" { return; } - if let Some(pos) = self.stack.iter().rposition(|(_, name)| *name == tag) { + 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: &str, - args: SmallVec<[Cow<'_, str>; 4]>, + 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 tag_lower = tag_name.to_lowercase(); + let lower = tag_name.to_lowercase(); - match tag_lower.as_str() { + 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" => { - self.handle_decoration_tag(&tag_lower, parent, self_closing) + 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" => self.handle_shadow_tag(args, parent, self_closing), + "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); - let idx = self.push_format_wrapper(parent, fmt, self_closing); - if !self_closing { - self.stack.push((idx, "!shadow".to_string())); - } + self.push_format_tag(parent, fmt, self_closing, "!shadow"); } "color" | "c" | "colour" => { - self.handle_verbose_color_tag(tag_lower, args, parent, self_closing) + 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(Cow::Owned(text.into_owned())); + 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: Cow::Owned(keybind), + }); + self.add_child_node(parent, comp); + } + "lang" | "tr" | "translate" => { + self.handle_translate_tag(args, parent, None); } - "click" => self.handle_click_tag(args, parent, self_closing), - "hover" => self.handle_hover_tag(args, parent, self_closing), - "insert" => self.handle_insertion_tag(args, parent, self_closing), - "font" => self.handle_font_tag(args, parent, self_closing), - "key" => self.handle_keybind_tag(args, parent), - "lang" | "tr" | "translate" => self.handle_translate_tag(args, parent, None), "lang_or" | "tr_or" | "translate_or" => { - self.handle_translate_tag(args, parent, Some(true)) + self.handle_translate_tag(args, parent, Some(true)); } "newline" | "br" => { self.add_child_node(parent, TextComponent::plain("\n")); } - "selector" | "sel" => self.handle_selector_tag(args, parent), - "score" => self.handle_score_tag(args, parent), - "nbt" | "data" => self.handle_nbt_tag(args, parent), - "sprite" => self.handle_sprite_tag(args, parent), - "head" => self.handle_head_tag(args, parent), + "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: Cow::Owned(name.into_owned()), + objective: Cow::Owned(objective.into_owned()), + }; + 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: atlas.map(|a| Cow::Owned(a.into_owned())), + sprite: Cow::Owned(sprite.into_owned()), + })); + self.add_child_node(parent, comp); + } + "head" => { + self.handle_head_tag(args, parent); + } #[cfg(feature = "custom")] - "rainbow" => self.push_custom_tag(parent, "rainbow", tag_lower, self_closing), + "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" => self.push_custom_tag(parent, "gradient", tag_lower, self_closing), + "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" => self.push_custom_tag(parent, "transition", tag_lower, self_closing), + "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" => self.push_custom_tag(parent, "pride", tag_lower, self_closing), + "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(&tag_lower).or_else(|| { - if tag_lower.starts_with('#') { - Color::from_hex(&tag_lower) + let color = parse_color(&lower).or_else(|| { + if lower.starts_with('#') { + Color::from_hex(&lower) } else { None } }); if let Some(color) = color { - let idx = - self.push_format_wrapper(parent, Format::new().color(color), self_closing); - if !self_closing { - self.stack.push((idx, tag_lower.to_string())); - } + self.push_format_tag( + parent, + Format::new().color(color), + self_closing, + tag_name, + ); } } } } - #[cfg(feature = "custom")] - fn push_custom_tag(&mut self, parent: usize, id: &str, tag: String, self_closing: bool) { - let comp = new_component(Content::Custom(CustomData { - id: Cow::Borrowed(id), - payload: Payload::Empty, - })); - self.push_tag_to_stack( - parent, - comp, - if self_closing { None } else { Some(tag) }, - self_closing, - ); - } - - fn handle_decoration_tag(&mut self, tag: &str, parent: usize, self_closing: bool) { - let (decoration, value) = if let Some(rest) = tag.strip_prefix('!') { - (rest, false) - } else { - (tag, 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, - }; - - let idx = self.push_format_wrapper(parent, format, self_closing); - if !self_closing { - self.stack.push((idx, tag.to_string())); - } - } - - fn handle_shadow_tag( - &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - let format = parse_shadow(&args); - let idx = self.push_format_wrapper(parent, format, self_closing); - if !self_closing { - self.stack.push((idx, "shadow".to_string())); - } - } - - fn handle_verbose_color_tag( - &mut self, - tag: String, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - let color = args.first().and_then(|a| parse_color(a)); - let format = match color { - Some(c) => Format::new().color(c), - None => Format::new(), - }; - let idx = self.push_format_wrapper(parent, format, self_closing); - if !self_closing { - self.stack.push((idx, tag)); - } - } - - fn handle_click_tag( - &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - if args.len() >= 2 { - let action = take_arg(args[0].clone()); - let value: String = args[1..].iter().fold(String::new(), |mut acc, a| { - if !acc.is_empty() { - acc.push(':'); - } - acc.push_str(a.as_ref()); - acc - }); - let click = parse_click(&action, &value); - let comp = new_component(Content::Text { - text: Cow::Borrowed(""), - }); - let mut comp = comp; - comp.interactions.click = click; - let tag_name = if self_closing { - None - } else { - Some("click".to_string()) - }; - self.push_tag_to_stack(parent, comp, tag_name, self_closing); - } - } - - fn handle_hover_tag( - &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - let hover = parse_hover(&args); - let mut comp = new_component(Content::Text { - text: Cow::Borrowed(""), - }); - comp.interactions.hover = hover; - let tag_name = if self_closing { - None - } else { - Some("hover".to_string()) - }; - self.push_tag_to_stack(parent, comp, tag_name, self_closing); - } - - fn handle_insertion_tag( - &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - if let Some(text) = args.first() { - let mut comp = new_component(Content::Text { - text: Cow::Borrowed(""), - }); - comp.interactions.insertion = Some(Cow::Owned(take_arg(text.clone()))); - let tag_name = if self_closing { - None - } else { - Some("insert".to_string()) - }; - self.push_tag_to_stack(parent, comp, tag_name, self_closing); - } - } - - fn handle_font_tag( - &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, - parent: usize, - self_closing: bool, - ) { - let font = args.into_iter().fold(String::new(), |mut acc, a| { - if !acc.is_empty() { - acc.push(':'); - } - acc.push_str(a.as_ref()); - acc - }); - let idx = self.push_format_wrapper(parent, Format::new().font(font), self_closing); - if !self_closing { - self.stack.push((idx, "font".to_string())); - } - } - - fn handle_keybind_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { - let keybind = args.into_iter().fold(String::new(), |mut acc, a| { - if !acc.is_empty() { - acc.push(':'); - } - acc.push_str(a.as_ref()); - acc - }); - let comp = new_component(Content::Keybind { - keybind: Cow::Owned(keybind), - }); - self.add_child_node(parent, comp); - } - fn handle_translate_tag( &mut self, - args: SmallVec<[Cow<'_, str>; 4]>, + args: SmallVec<[Cow<'a, str>; 4]>, parent: usize, has_fallback: Option, ) { let mut args = args; - let (key, fallback) = match has_fallback { - None => (take_first_arg(&mut args), None), - Some(_) => { - let key = take_first_arg(&mut args); - let fb = take_first_arg(&mut args).map(Cow::Owned); - (key, fb) - } + let key = take_first_arg(&mut args); + let fallback = if has_fallback.is_some() { + take_first_arg(&mut args).map(|c| Cow::Owned(c.into_owned())) + } else { + None }; if let Some(key) = key { @@ -442,7 +417,7 @@ impl Parser { .map(|a| parse_minimessage(a.as_ref())) .collect(); let msg = TranslatedMessage { - key: Cow::Owned(key), + key: Cow::Owned(key.into_owned()), fallback, args: if t_args.is_empty() { None @@ -455,7 +430,7 @@ impl Parser { } } - fn handle_selector_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { + 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) { @@ -464,7 +439,7 @@ impl Parser { Resolvable::entity_separator() }; let resolvable = Resolvable::Entity { - selector: Cow::Owned(sel), + selector: Cow::Owned(sel.into_owned()), separator, }; let comp = new_component(Content::Resolvable(resolvable)); @@ -472,73 +447,45 @@ impl Parser { } } - fn handle_score_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { + fn handle_nbt_tag(&mut self, args: SmallVec<[Cow<'a, str>; 4]>, parent: usize) { 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: Cow::Owned(name), - objective: Cow::Owned(objective), - }; - let comp = new_component(Content::Resolvable(resolvable)); - self.add_child_node(parent, comp); - } - } - - fn handle_nbt_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { - let args = args; if args.len() >= 3 { - let source_type = take_arg(args[0].clone()); - let id = take_arg(args[1].clone()); - let path = take_arg(args[2].clone()); - let separator = if args.get(3).is_some() { - let sep = take_arg(args[3].clone()); - Box::new(parse_minimessage(&sep)) - } else { - Resolvable::nbt_separator() - }; - let interpret = args.get(4).is_some_and(|v| v.as_ref() == "interpret"); - let source = match source_type.as_str() { - "entity" => NbtSource::Entity(Cow::Owned(id)), - "block" => NbtSource::Block(Cow::Owned(id)), - "storage" => NbtSource::Storage(Cow::Owned(id)), - _ => return, - }; - let resolvable = Resolvable::NBT { - path: Cow::Owned(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_sprite_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { - let mut args = args; - let (atlas, sprite) = if args.len() == 1 { - (None, take_first_arg(&mut args).unwrap_or_default()) - } else if args.len() >= 2 { - let atlas = take_first_arg(&mut args); - let sprite = take_first_arg(&mut args).unwrap_or_default(); - (atlas, sprite) - } else { - return; - }; - let comp = new_component(Content::Object(Object::Atlas { - atlas: atlas.map(Cow::Owned), - sprite: Cow::Owned(sprite), - })); - self.add_child_node(parent, comp); - } - - fn handle_head_tag(&mut self, args: SmallVec<[Cow<'_, str>; 4]>, parent: usize) { + let source_type = take_first_arg(&mut args) + .map(|c| c.into_owned()) + .unwrap_or_default(); + let id = take_first_arg(&mut args).map(|c| c.into_owned()); + let path = take_first_arg(&mut args).map(|c| c.into_owned()); + if let (Some(id), Some(path)) = (id, path) { + let sep = take_first_arg(&mut args).map(|c| c.into_owned()); + let separator = if let Some(s) = sep { + Box::new(parse_minimessage(&s)) + } else { + Resolvable::nbt_separator() + }; + let interpret = args.first().is_some_and(|v| v.as_ref() == "interpret"); + let source = match source_type.as_str() { + "entity" => NbtSource::Entity(Cow::Owned(id)), + "block" => NbtSource::Block(Cow::Owned(id)), + "storage" => NbtSource::Storage(Cow::Owned(id)), + _ => return, + }; + let resolvable = Resolvable::NBT { + path: Cow::Owned(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) { + 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, @@ -548,9 +495,9 @@ impl Parser { ]; ObjectPlayer::id(id) } else if head_str.contains('/') || head_str.contains(':') { - ObjectPlayer::texture(head_str) + ObjectPlayer::texture(head_str.into_owned()) } else { - ObjectPlayer::name(head_str) + ObjectPlayer::name(head_str.into_owned()) }; let comp = new_component(Content::Object(Object::Player { player, @@ -576,22 +523,11 @@ impl Parser { } } -fn take_arg(cow: Cow<'_, str>) -> String { - match cow { - Cow::Borrowed(s) => (*s).to_string(), - Cow::Owned(s) => s.clone(), - } -} - -fn take_first_arg(args: &mut SmallVec<[Cow<'_, str>; 4]>) -> Option { +fn take_first_arg<'a>(args: &mut SmallVec<[Cow<'a, str>; 4]>) -> Option> { if args.is_empty() { return None; } - let cow = args.remove(0); - Some(match cow { - Cow::Borrowed(s) => s.to_string(), - Cow::Owned(s) => s, - }) + Some(args.remove(0)) } fn unescape_text(s: &str) -> Cow<'_, str> { @@ -774,37 +710,40 @@ fn color_to_rgb(color: &Color) -> (u8, u8, u8) { } fn parse_click(action: &str, value: &str) -> Option { - match action { - "open_url" => Some(ClickEvent::OpenUrl { - url: Cow::Owned(value.to_string()), - }), - "run_command" => Some(ClickEvent::RunCommand { - command: Cow::Owned(value.to_string()), - }), - "suggest_command" => Some(ClickEvent::SuggestCommand { - command: Cow::Owned(value.to_string()), - }), - "change_page" => value - .parse::() - .ok() - .map(|page| ClickEvent::ChangePage { page }), - "copy_to_clipboard" => Some(ClickEvent::CopyToClipboard { - value: Cow::Owned(value.to_string()), - }), - "show_dialog" => Some(ClickEvent::ShowDialog { - dialog: Cow::Owned(value.to_string()), - }), + let value_str = value.to_string(); + let event = match action { + "open_url" => ClickEvent::OpenUrl { + url: Cow::Owned(value_str.clone()), + }, + "run_command" => ClickEvent::RunCommand { + command: Cow::Owned(value_str.clone()), + }, + "suggest_command" => ClickEvent::SuggestCommand { + command: Cow::Owned(value_str.clone()), + }, + "change_page" => { + let page = value.parse::().ok()?; + ClickEvent::ChangePage { page } + } + "copy_to_clipboard" => ClickEvent::CopyToClipboard { + value: Cow::Owned(value_str.clone()), + }, + "show_dialog" => ClickEvent::ShowDialog { + dialog: Cow::Owned(value_str.clone()), + }, #[cfg(feature = "custom")] - "custom" => Some(ClickEvent::Custom(CustomData { - id: Cow::Owned(value.to_string()), + "custom" => ClickEvent::Custom(CustomData { + id: Cow::Owned(value_str), payload: Payload::Empty, - })), - _ => None, - } + }), + _ => return None, + }; + Some(event) } fn parse_hover(args: &[Cow]) -> Option { - match args.first()?.as_ref() { + let first = args.first()?.as_ref(); + match first { "show_text" => { let text = parse_minimessage(args.get(1)?.as_ref()); Some(HoverEvent::ShowText { @@ -845,7 +784,12 @@ fn parse_shadow(args: &[Cow]) -> Format { return format; } let color_arg = &args[0]; - if let Some(hex) = color_arg.strip_prefix('#') { + 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.strip_prefix('#') { if hex.len() == 8 { if let (Ok(r), Ok(g), Ok(b), Ok(a)) = ( u8::from_str_radix(&hex[0..2], 16), @@ -853,824 +797,33 @@ fn parse_shadow(args: &[Cow]) -> Format { u8::from_str_radix(&hex[4..6], 16), u8::from_str_radix(&hex[6..8], 16), ) { - format.shadow_color = Some(Format::parse_shadow_color(a, r, g, b)); - return format; + Some(Format::parse_shadow_color(a, r, g, b)) + } else { + None } } else if hex.len() == 6 { - let alpha = args - .get(1) - .and_then(|a| a.parse::().ok()) - .map(|f| (f * 255.0).round() as u8) - .unwrap_or(64); 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), ) { - format.shadow_color = Some(Format::parse_shadow_color(alpha, r, g, b)); - return format; + 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) { let (r, g, b) = color_to_rgb(&color); - let alpha = args - .get(1) - .and_then(|a| a.parse::().ok()) - .map(|f| (f * 255.0).round() as u8) - .unwrap_or(64); - format.shadow_color = Some(Format::parse_shadow_color(alpha, r, g, b)); + 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 } - -#[cfg(test)] -mod tests { - use super::*; - use crate::content::{Content, NbtSource, Object, Resolvable}; - use crate::format::{Color, Format}; - use crate::interactivity::{ClickEvent, HoverEvent}; - - fn first_child(comp: &TextComponent) -> &TextComponent { - comp.children.first().expect("expected at least one child") - } - - fn children(comp: &TextComponent) -> &[TextComponent] { - &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"), - } - } -} diff --git a/src/minimessage_tests.rs b/src/minimessage_tests.rs new file mode 100644 index 0000000..baf4bf2 --- /dev/null +++ b/src/minimessage_tests.rs @@ -0,0 +1,793 @@ +use std::borrow::Cow; + +use crate::{ + TextComponent, + content::{Content, NbtSource, Object, Resolvable}, + format::{Color, Format}, + interactivity::{ClickEvent, HoverEvent}, + minimessage::*, +}; + +fn first_child(comp: &TextComponent) -> &TextComponent { + comp.children.first().expect("expected at least one child") +} + +fn children(comp: &TextComponent) -> &[TextComponent] { + &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"), + } +} From 21dc8d5608f020bd07656f974439a17f3ae1601a Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 9 May 2026 11:05:02 +0300 Subject: [PATCH 05/15] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e9c8b4..52c6a02 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 +- [x] MiniMessage integration - [ ] Extensibility integration ### Test From 75eee7e9ed851451169d008938532e577dc44acc Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 9 May 2026 21:59:43 +0300 Subject: [PATCH 06/15] Update for using 'a instead of 'static --- Cargo.lock | 82 ++++++++++++++++++++ Cargo.toml | 3 + examples/main.rs | 53 +++++++------ examples/nbt.rs | 8 +- examples/serde.rs | 6 +- examples/snbt.rs | 4 +- src/content.rs | 120 +++++++++++++++-------------- src/custom.rs | 37 +++++---- src/fmt.rs | 54 ++++++------- src/format.rs | 14 ++-- src/interactivity.rs | 64 ++++++++-------- src/lib.rs | 158 ++++++++++++++++++++------------------- src/minimessage.rs | 53 +++++++------ src/minimessage_tests.rs | 6 +- src/nbt.rs | 50 ++++++------- src/parse/mod.rs | 56 +++++++------- src/parse/nbt.rs | 12 +-- src/resolving.rs | 48 ++++++------ src/translation.rs | 44 ++++++----- 19 files changed, 499 insertions(+), 373 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21f8b2d..c1fab0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,41 @@ 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 = "equivalent" version = "1.0.2" @@ -142,6 +177,12 @@ 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" @@ -237,6 +278,12 @@ 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.14.0" @@ -316,6 +363,34 @@ 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" @@ -508,6 +583,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[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" @@ -533,6 +614,7 @@ dependencies = [ "colored", "heck", "memchr", + "ownable", "proc-macro2", "quote", "rand", diff --git a/Cargo.toml b/Cargo.toml index 2784058..acea534 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ categories = [ custom = [] nbt = ["dep:simdnbt"] serde = ["dep:serde"] +ownable = ["dep:ownable"] +minimessage = ["ownable"] build = [ "dep:heck", "dep:proc-macro2", @@ -48,6 +50,7 @@ uuid = { version = "1.23", features = ["v4", "serde"] } supports-hyperlinks = "3.2" memchr = "2.8" smallvec = "1.15" +ownable = { version = "1.0", optional = true } [dev-dependencies] chrono = "0.4" 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..74d1c45 100644 --- a/src/content.rs +++ b/src/content.rs @@ -1,30 +1,31 @@ #[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 = "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 +34,19 @@ impl From for Content { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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 +55,14 @@ pub enum Object { }, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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 +72,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: T) -> Self { ObjectPlayer { name: Some(name.into()), id: None, @@ -96,7 +99,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: T) -> Self { ObjectPlayer { name: None, id: None, @@ -107,7 +110,7 @@ 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>>( + pub fn property>, R: Into>>( value: T, signature: Option, ) -> Self { @@ -131,36 +134,38 @@ impl ObjectPlayer { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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 = "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 +174,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 +192,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 +203,17 @@ impl Resolvable { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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: T) -> Self { NbtSource::Entity(selector.into()) } /// Creates a [NbtSource] from a block coordinates. @@ -215,14 +221,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: T) -> 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 +236,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 +246,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 +259,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..cc80de8 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -1,10 +1,11 @@ -use crate::TextComponent; +use crate::RawTextComponent; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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 +14,7 @@ pub struct CustomData { } #[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +#[cfg_attr(feature = "ownable", derive(::ownable::IntoOwned, ::ownable::ToOwned))] #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] pub enum Payload { #[default] @@ -25,29 +27,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 9116337..0f9299e 100644 --- a/src/format.rs +++ b/src/format.rs @@ -2,8 +2,9 @@ use colored::{ColoredString, Colorize}; use std::{borrow::Cow, fmt::Display}; #[derive(Clone, PartialEq, Eq, Hash)] +#[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 +14,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 +47,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 +85,7 @@ impl Format { } self } - pub fn font>>(mut self, font: F) -> Self { + pub fn font>>(mut self, font: F) -> Self { self.font = Some(font.into()); self } @@ -126,7 +127,7 @@ 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 @@ -173,6 +174,7 @@ impl Format { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[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 { diff --git a/src/interactivity.rs b/src/interactivity.rs index 7fd7c1b..a9be36e 100644 --- a/src/interactivity.rs +++ b/src/interactivity.rs @@ -1,18 +1,19 @@ use uuid::Uuid; -use crate::TextComponent; +use crate::RawTextComponent; #[cfg(feature = "custom")] use crate::custom::CustomData; use std::borrow::Cow; #[derive(Clone, PartialEq, Eq, Hash)] +#[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> { #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - pub insertion: Option>, + pub insertion: Option>, #[cfg_attr( feature = "serde", serde( @@ -21,7 +22,7 @@ pub struct Interactivity { default ) )] - pub click: Option, + pub click: Option>, #[cfg_attr( feature = "serde", serde( @@ -30,16 +31,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, @@ -64,43 +65,44 @@ impl Interactivity { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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> { OpenUrl { - url: Cow<'static, str>, + url: Cow<'a, str>, }, RunCommand { - command: Cow<'static, str>, + command: Cow<'a, str>, }, SuggestCommand { - command: Cow<'static, str>, + command: Cow<'a, str>, }, ChangePage { page: i32, }, CopyToClipboard { - value: Cow<'static, str>, + value: Cow<'a, str>, }, ShowDialog { - dialog: Cow<'static, str>, + dialog: Cow<'a, str>, }, #[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: T) -> 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: T) -> 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: T) -> Self { ClickEvent::SuggestCommand { command: command.into(), } @@ -110,14 +112,14 @@ impl ClickEvent { 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: T) -> 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: T) -> Self { ClickEvent::ShowDialog { dialog: dialog.into(), } @@ -125,14 +127,15 @@ impl ClickEvent { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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> { ShowText { - value: Box, + value: Box>, }, ShowItem { - id: Cow<'static, str>, + id: Cow<'a, str>, #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) @@ -142,21 +145,22 @@ pub enum HoverEvent { feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - components: Option>, + components: Option>, }, ShowEntity { #[cfg_attr( feature = "serde", serde(skip_serializing_if = "Option::is_none", default) )] - name: Option>, - id: Cow<'static, str>, + name: Option>>, + id: Cow<'a, str>, + #[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: T) -> Self { HoverEvent::ShowText { value: Box::new(text.into()), } @@ -165,7 +169,7 @@ impl HoverEvent { /// * `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>>( + pub fn show_item>, R: Into>>( id: T, count: Option, components: Option, @@ -180,7 +184,7 @@ impl HoverEvent { /// * `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>( + pub fn show_entity>, R: Into>>( id: T, uuid: Uuid, name: Option, diff --git a/src/lib.rs b/src/lib.rs index 7960a6b..8f60082 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,9 @@ 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")] @@ -87,26 +89,29 @@ pub mod translation; /// component.to_pretty(resolutor); /// ``` #[derive(Clone, PartialEq, Eq, Hash)] +#[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(""), }, @@ -122,8 +127,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), }, @@ -143,8 +148,8 @@ impl TextComponent { /// ``` /// let component: TextComponent = "Test Component".into(); /// ``` - pub fn plain>>(text: T) -> Self { - TextComponent { + pub fn plain>>(text: T) -> Self { + RawTextComponent { content: Content::Text { text: text.into() }, children: vec![], format: Format::new(), @@ -173,8 +178,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(), @@ -190,11 +195,11 @@ impl TextComponent { /// // Displays the Diamond Sword sprite /// TextComponent::atlas("item/diamond_sword", Some("minecraft:items")); /// ``` - pub fn atlas>, R: Into>>( + pub fn atlas>, R: Into>>( sprite: T, atlas: Option, ) -> Self { - TextComponent { + RawTextComponent { content: Content::Object(Object::Atlas { atlas: atlas.map(Into::into), sprite: sprite.into(), @@ -212,8 +217,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(), @@ -231,11 +236,11 @@ impl TextComponent { /// TextComponent::scoreboard("@p", "deaths"); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn scoreboard>, R: Into>>( + pub fn scoreboard>, R: Into>>( selector: T, objective: R, ) -> Self { - TextComponent { + RawTextComponent { content: Content::Resolvable(Resolvable::Scoreboard { selector: selector.into(), objective: objective.into(), @@ -255,8 +260,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: T, separator: Option) -> Self { + RawTextComponent { content: Content::Resolvable(Resolvable::Entity { selector: selector.into(), separator: match separator { @@ -281,13 +286,13 @@ impl TextComponent { /// TextComponent::nbt("Health", NbtSource::entity("@p"), false, None); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn nbt>>( + pub fn nbt>>( path: T, - source: NbtSource, + 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 }, @@ -304,8 +309,8 @@ impl TextComponent { } #[cfg(feature = "custom")] - pub fn custom(content: T) -> TextComponent { - TextComponent { + pub fn custom + 'a>(content: T) -> RawTextComponent<'a> { + RawTextComponent { content: Content::Custom(content.as_data()), children: vec![], format: Format::new(), @@ -314,43 +319,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* @@ -367,14 +372,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()); @@ -382,151 +387,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); diff --git a/src/minimessage.rs b/src/minimessage.rs index 58b1070..41cff08 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -1,7 +1,7 @@ use smallvec::SmallVec; use crate::{ - TextComponent, + RawTextComponent, TextComponent, content::{Content, NbtSource, Object, ObjectPlayer, Resolvable}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent}, @@ -13,11 +13,14 @@ use std::borrow::Cow; use crate::custom::{CustomData, Payload}; pub fn parse(input: &str) -> TextComponent { + parse_raw(input).into_owned() +} +pub fn parse_raw<'a>(input: &'a str) -> RawTextComponent<'a> { Parser::parse(input) } -fn new_component(content: Content) -> TextComponent { - TextComponent { +fn new_component(content: Content) -> RawTextComponent { + RawTextComponent { content, ..Default::default() } @@ -35,15 +38,15 @@ fn join_with_colon(args: &[Cow]) -> String { } struct Parser<'a> { - nodes: Vec, + nodes: Vec>, children: Vec>, stack: Vec<(usize, Cow<'a, str>)>, } impl<'a> Parser<'a> { - fn parse(input: &'a str) -> TextComponent { + fn parse(input: &'a str) -> RawTextComponent<'a> { let mut parser = Parser { - nodes: vec![TextComponent::new()], + nodes: vec![RawTextComponent::new()], children: vec![Vec::new()], stack: vec![(0, Cow::Borrowed(""))], }; @@ -62,7 +65,7 @@ impl<'a> Parser<'a> { if i > start { let text = unescape_text(&input[start..i]); if !text.is_empty() { - let comp = TextComponent::plain(text.into_owned()); + let comp = RawTextComponent::plain(text.into_owned()); let parent = parser.stack.last().unwrap().0; parser.add_child_node(parent, comp); } @@ -123,7 +126,7 @@ impl<'a> Parser<'a> { parser.finish() } - fn add_child_node(&mut self, parent: usize, child: TextComponent) -> usize { + 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() @@ -149,7 +152,7 @@ impl<'a> Parser<'a> { fn push_tag_to_stack( &mut self, parent: usize, - comp: TextComponent, + comp: RawTextComponent<'a>, tag_name: Option>, self_closing: bool, ) -> usize { @@ -163,11 +166,11 @@ impl<'a> Parser<'a> { fn push_format_tag( &mut self, parent: usize, - format: Format, + format: Format<'a>, self_closing: bool, tag: &'a str, ) -> usize { - let comp = TextComponent { + let comp = RawTextComponent { content: Content::Text { text: Cow::Borrowed(""), }, @@ -298,7 +301,7 @@ impl<'a> Parser<'a> { self.handle_translate_tag(args, parent, Some(true)); } "newline" | "br" => { - self.add_child_node(parent, TextComponent::plain("\n")); + self.add_child_node(parent, RawTextComponent::plain("\n")); } "selector" | "sel" => { self.handle_selector_tag(args, parent); @@ -412,9 +415,9 @@ impl<'a> Parser<'a> { }; if let Some(key) = key { - let t_args: Vec = args + let t_args: Vec = args .into_iter() - .map(|a| parse_minimessage(a.as_ref())) + .map(|a| parse_minimessage(a.as_ref()).into_owned()) .collect(); let msg = TranslatedMessage { key: Cow::Owned(key.into_owned()), @@ -434,7 +437,7 @@ impl<'a> Parser<'a> { let mut args = args; if let Some(sel) = take_first_arg(&mut args) { let separator = if let Some(sep) = take_first_arg(&mut args) { - Box::new(parse_minimessage(&sep)) + Box::new(parse_minimessage(&sep).into_owned()) } else { Resolvable::entity_separator() }; @@ -458,7 +461,7 @@ impl<'a> Parser<'a> { if let (Some(id), Some(path)) = (id, path) { let sep = take_first_arg(&mut args).map(|c| c.into_owned()); let separator = if let Some(s) = sep { - Box::new(parse_minimessage(&s)) + Box::new(parse_minimessage(&s).into_owned()) } else { Resolvable::nbt_separator() }; @@ -507,12 +510,12 @@ impl<'a> Parser<'a> { } } - fn finish(mut self) -> TextComponent { + fn finish(mut self) -> RawTextComponent<'a> { self.stack.truncate(1); self.build_node(0) } - fn build_node(&mut self, idx: usize) -> TextComponent { + 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 @@ -709,7 +712,7 @@ fn color_to_rgb(color: &Color) -> (u8, u8, u8) { } } -fn parse_click(action: &str, value: &str) -> Option { +fn parse_click<'a>(action: &str, value: &str) -> Option> { let value_str = value.to_string(); let event = match action { "open_url" => ClickEvent::OpenUrl { @@ -741,11 +744,11 @@ fn parse_click(action: &str, value: &str) -> Option { Some(event) } -fn parse_hover(args: &[Cow]) -> Option { +fn parse_hover<'a>(args: &[Cow<'a, str>]) -> Option> { let first = args.first()?.as_ref(); match first { "show_text" => { - let text = parse_minimessage(args.get(1)?.as_ref()); + let text = parse_minimessage(args.get(1)?.as_ref()).into_owned(); Some(HoverEvent::ShowText { value: Box::new(text), }) @@ -763,7 +766,9 @@ fn parse_hover(args: &[Cow]) -> Option { "show_entity" => { let id = args.get(1)?.to_string(); let uuid = uuid::Uuid::parse_str(args.get(2)?.as_ref()).ok()?; - let name = args.get(3).map(|s| Box::new(parse_minimessage(s.as_ref()))); + let name = args + .get(3) + .map(|s| Box::new(parse_minimessage(s.as_ref()).into_owned())); Some(HoverEvent::ShowEntity { name, id: Cow::Owned(id), @@ -774,11 +779,11 @@ fn parse_hover(args: &[Cow]) -> Option { } } -fn parse_minimessage(s: &str) -> TextComponent { +fn parse_minimessage<'a>(s: &'a str) -> RawTextComponent<'a> { parse(s) } -fn parse_shadow(args: &[Cow]) -> Format { +fn parse_shadow<'a>(args: &[Cow<'a, str>]) -> Format<'a> { let mut format = Format::new(); if args.is_empty() { return format; diff --git a/src/minimessage_tests.rs b/src/minimessage_tests.rs index baf4bf2..65fbb32 100644 --- a/src/minimessage_tests.rs +++ b/src/minimessage_tests.rs @@ -1,18 +1,18 @@ use std::borrow::Cow; use crate::{ - TextComponent, + RawTextComponent, content::{Content, NbtSource, Object, Resolvable}, format::{Color, Format}, interactivity::{ClickEvent, HoverEvent}, minimessage::*, }; -fn first_child(comp: &TextComponent) -> &TextComponent { +fn first_child<'a>(comp: &'a RawTextComponent<'a>) -> &'a RawTextComponent<'a> { comp.children.first().expect("expected at least one child") } -fn children(comp: &TextComponent) -> &[TextComponent] { +fn children<'a>(comp: &'a RawTextComponent<'a>) -> &'a [RawTextComponent<'a>] { &comp.children } diff --git a/src/nbt.rs b/src/nbt.rs index ab9ef67..aa3c058 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,7 +46,7 @@ impl BuildTarget for NbtBuilder { } } -impl TextComponent { +impl<'a> RawTextComponent<'a> { pub fn nbt_display>(tag: T) -> Self { let tag = tag.into(); match tag { @@ -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..f682135 100644 --- a/src/resolving.rs +++ b/src/resolving.rs @@ -3,18 +3,18 @@ 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()) +pub trait TextResolutor<'a> { + fn resolve_other(&self, content: &Content<'a>) -> RawTextComponent<'a> { + RawTextComponent::from(content.clone()) } - fn resolve_content(&self, resolvable: &Resolvable) -> TextComponent; + fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a>; #[cfg(feature = "custom")] - fn resolve_custom(&self, data: &CustomData) -> Option; + fn resolve_custom(&self, data: &CustomData<'a>) -> Option>; fn translate(&self, key: &str) -> Option; fn split_translation(&self, text: String) -> Vec<(String, usize)> { let mut positions = vec![(0, 0, 0), (text.len(), 0, 0)]; @@ -39,13 +39,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) } @@ -59,22 +59,22 @@ impl TextResolutor for Arc { } 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 +82,8 @@ impl TextResolutor for NoResolutor { } } -impl TextComponent { - pub fn build( +impl<'a> RawTextComponent<'a> { + pub fn build + ?Sized, S: BuildTarget<'a>>( &self, resolutor: &R, target: S, @@ -91,12 +91,12 @@ impl TextComponent { target.build_component(resolutor, &self.resolve(resolutor)) } - pub fn resolve(&self, resolutor: &R) -> TextComponent { + 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 +106,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 +133,11 @@ impl TextComponent { } } -pub trait BuildTarget { +pub trait BuildTarget<'a> { type Result; - fn build_component( + 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..9d8dc83 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -1,28 +1,29 @@ -use crate::TextComponent; +use crate::RawTextComponent; use std::borrow::Cow; #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[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 +32,45 @@ 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: F, + ) -> 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 262e8280c11a0d3b41581d60dd03667ce11469f0 Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 9 May 2026 22:42:46 +0300 Subject: [PATCH 07/15] Update code --- src/minimessage.rs | 153 ++++++++++++++++++++++----------------------- 1 file changed, 76 insertions(+), 77 deletions(-) diff --git a/src/minimessage.rs b/src/minimessage.rs index 41cff08..b204826 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -19,14 +19,20 @@ pub fn parse_raw<'a>(input: &'a str) -> RawTextComponent<'a> { Parser::parse(input) } -fn new_component(content: Content) -> RawTextComponent { +fn new_component<'a>(content: Content<'a>) -> RawTextComponent<'a> { RawTextComponent { content, ..Default::default() } } -fn join_with_colon(args: &[Cow]) -> String { +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() { @@ -34,7 +40,7 @@ fn join_with_colon(args: &[Cow]) -> String { } result.push_str(a.as_ref()); } - result + Cow::Owned(result) } struct Parser<'a> { @@ -65,7 +71,7 @@ impl<'a> Parser<'a> { if i > start { let text = unescape_text(&input[start..i]); if !text.is_empty() { - let comp = RawTextComponent::plain(text.into_owned()); + let comp = RawTextComponent::plain(text); let parent = parser.stack.last().unwrap().0; parser.add_child_node(parent, comp); } @@ -247,7 +253,7 @@ impl<'a> Parser<'a> { 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 click = parse_click(&action, value); let mut comp = new_component(Content::Text { text: Cow::Borrowed(""), }); @@ -261,7 +267,7 @@ impl<'a> Parser<'a> { } } "hover" => { - let hover = parse_hover(&args); + let hover = parse_hover(args); let mut comp = new_component(Content::Text { text: Cow::Borrowed(""), }); @@ -274,7 +280,7 @@ impl<'a> Parser<'a> { let mut comp = new_component(Content::Text { text: Cow::Borrowed(""), }); - comp.interactions.insertion = Some(Cow::Owned(text.into_owned())); + comp.interactions.insertion = Some(text); self.push_tag_to_stack( parent, comp, @@ -289,9 +295,7 @@ impl<'a> Parser<'a> { } "key" => { let keybind = join_with_colon(&args); - let comp = new_component(Content::Keybind { - keybind: Cow::Owned(keybind), - }); + let comp = new_component(Content::Keybind { keybind }); self.add_child_node(parent, comp); } "lang" | "tr" | "translate" => { @@ -312,8 +316,8 @@ impl<'a> Parser<'a> { (take_first_arg(&mut args), take_first_arg(&mut args)) { let resolvable = Resolvable::Scoreboard { - selector: Cow::Owned(name.into_owned()), - objective: Cow::Owned(objective.into_owned()), + selector: name, + objective, }; let comp = new_component(Content::Resolvable(resolvable)); self.add_child_node(parent, comp); @@ -333,10 +337,7 @@ impl<'a> Parser<'a> { None }; let sprite = take_first_arg(&mut args).unwrap(); - let comp = new_component(Content::Object(Object::Atlas { - atlas: atlas.map(|a| Cow::Owned(a.into_owned())), - sprite: Cow::Owned(sprite.into_owned()), - })); + let comp = new_component(Content::Object(Object::Atlas { atlas, sprite })); self.add_child_node(parent, comp); } "head" => { @@ -409,18 +410,21 @@ impl<'a> Parser<'a> { let mut args = args; let key = take_first_arg(&mut args); let fallback = if has_fallback.is_some() { - take_first_arg(&mut args).map(|c| Cow::Owned(c.into_owned())) + take_first_arg(&mut args) } else { None }; if let Some(key) = key { - let t_args: Vec = args + let t_args: Vec> = args .into_iter() - .map(|a| parse_minimessage(a.as_ref()).into_owned()) + .map(|a| match a { + Cow::Borrowed(s) => parse_raw(s), + Cow::Owned(ref s) => parse(s), + }) .collect(); let msg = TranslatedMessage { - key: Cow::Owned(key.into_owned()), + key, fallback, args: if t_args.is_empty() { None @@ -437,12 +441,15 @@ impl<'a> Parser<'a> { let mut args = args; if let Some(sel) = take_first_arg(&mut args) { let separator = if let Some(sep) = take_first_arg(&mut args) { - Box::new(parse_minimessage(&sep).into_owned()) + 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: Cow::Owned(sel.into_owned()), + selector: sel, separator, }; let comp = new_component(Content::Resolvable(resolvable)); @@ -453,27 +460,28 @@ impl<'a> Parser<'a> { 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) - .map(|c| c.into_owned()) - .unwrap_or_default(); - let id = take_first_arg(&mut args).map(|c| c.into_owned()); - let path = take_first_arg(&mut args).map(|c| c.into_owned()); + 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).map(|c| c.into_owned()); + let sep = take_first_arg(&mut args); let separator = if let Some(s) = sep { - Box::new(parse_minimessage(&s).into_owned()) + 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_str() { - "entity" => NbtSource::Entity(Cow::Owned(id)), - "block" => NbtSource::Block(Cow::Owned(id)), - "storage" => NbtSource::Storage(Cow::Owned(id)), + 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: Cow::Owned(path), + path, interpret: if interpret { Some(true) } else { None }, separator, source, @@ -497,10 +505,10 @@ impl<'a> Parser<'a> { low as i32, ]; ObjectPlayer::id(id) - } else if head_str.contains('/') || head_str.contains(':') { - ObjectPlayer::texture(head_str.into_owned()) + } else if head_str.as_ref().contains('/') || head_str.as_ref().contains(':') { + ObjectPlayer::texture(head_str) } else { - ObjectPlayer::name(head_str.into_owned()) + ObjectPlayer::name(head_str) }; let comp = new_component(Content::Object(Object::Player { player, @@ -533,7 +541,7 @@ fn take_first_arg<'a>(args: &mut SmallVec<[Cow<'a, str>; 4]>) -> Option Cow<'_, str> { +fn unescape_text<'a>(s: &'a str) -> Cow<'a, str> { if !s.contains('\\') { return Cow::Borrowed(s); } @@ -712,31 +720,20 @@ fn color_to_rgb(color: &Color) -> (u8, u8, u8) { } } -fn parse_click<'a>(action: &str, value: &str) -> Option> { - let value_str = value.to_string(); +fn parse_click<'a>(action: &str, value: Cow<'a, str>) -> Option> { let event = match action { - "open_url" => ClickEvent::OpenUrl { - url: Cow::Owned(value_str.clone()), - }, - "run_command" => ClickEvent::RunCommand { - command: Cow::Owned(value_str.clone()), - }, - "suggest_command" => ClickEvent::SuggestCommand { - command: Cow::Owned(value_str.clone()), - }, + "open_url" => ClickEvent::OpenUrl { url: value }, + "run_command" => ClickEvent::RunCommand { command: value }, + "suggest_command" => ClickEvent::SuggestCommand { command: value }, "change_page" => { - let page = value.parse::().ok()?; + let page = value.as_ref().parse::().ok()?; ClickEvent::ChangePage { page } } - "copy_to_clipboard" => ClickEvent::CopyToClipboard { - value: Cow::Owned(value_str.clone()), - }, - "show_dialog" => ClickEvent::ShowDialog { - dialog: Cow::Owned(value_str.clone()), - }, + "copy_to_clipboard" => ClickEvent::CopyToClipboard { value }, + "show_dialog" => ClickEvent::ShowDialog { dialog: value }, #[cfg(feature = "custom")] "custom" => ClickEvent::Custom(CustomData { - id: Cow::Owned(value_str), + id: value, payload: Payload::Empty, }), _ => return None, @@ -744,45 +741,45 @@ fn parse_click<'a>(action: &str, value: &str) -> Option> { Some(event) } -fn parse_hover<'a>(args: &[Cow<'a, str>]) -> Option> { +fn parse_hover<'a>(args: SmallVec<[Cow<'a, str>; 4]>) -> Option> { let first = args.first()?.as_ref(); match first { "show_text" => { - let text = parse_minimessage(args.get(1)?.as_ref()).into_owned(); + 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)?.to_string(); + let id = args.get(1)?.clone(); let count = args.get(2).and_then(|s| s.parse::().ok()); - let components = args.get(3).map(|s| Cow::Owned(s.to_string())); + let components = args.get(3).cloned(); Some(HoverEvent::ShowItem { - id: Cow::Owned(id), + id, count, components, }) } "show_entity" => { - let id = args.get(1)?.to_string(); + let id = args.get(1)?.clone(); let uuid = uuid::Uuid::parse_str(args.get(2)?.as_ref()).ok()?; - let name = args - .get(3) - .map(|s| Box::new(parse_minimessage(s.as_ref()).into_owned())); - Some(HoverEvent::ShowEntity { - name, - id: Cow::Owned(id), - uuid, - }) + 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_minimessage<'a>(s: &'a str) -> RawTextComponent<'a> { - parse(s) -} - fn parse_shadow<'a>(args: &[Cow<'a, str>]) -> Format<'a> { let mut format = Format::new(); if args.is_empty() { @@ -794,7 +791,8 @@ fn parse_shadow<'a>(args: &[Cow<'a, str>]) -> Format<'a> { .and_then(|a| a.parse::().ok()) .map(|f| (f * 255.0).round() as u8) }; - let shadow = if let Some(hex) = color_arg.strip_prefix('#') { + + 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), @@ -820,13 +818,14 @@ fn parse_shadow<'a>(args: &[Cow<'a, str>]) -> Format<'a> { } else { None } - } else if let Some(color) = parse_color(color_arg) { + } 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); } From c5ef7f3aeed39bd61139380c972a5133cfe41f2d Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 9 May 2026 23:10:46 +0300 Subject: [PATCH 08/15] Add more tests --- src/lib.rs | 185 +++++++++++++++++++++++++++++++++++++++++++++ src/minimessage.rs | 9 ++- 2 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8f60082..26a29e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -317,6 +317,12 @@ impl<'a> RawTextComponent<'a> { interactions: Interactivity::new(), } } + + #[cfg(feature = "minimessage")] + pub fn minimessage>(text: T) -> RawTextComponent<'a> { + use crate::minimessage::Parser; + Parser::parse(text.into()) + } } impl<'a> Default for RawTextComponent<'a> { @@ -546,3 +552,182 @@ impl<'a> Modifier<'a> for &'a mut RawTextComponent<'a> { 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/minimessage.rs b/src/minimessage.rs index b204826..1b1ae6d 100644 --- a/src/minimessage.rs +++ b/src/minimessage.rs @@ -12,10 +12,11 @@ use std::borrow::Cow; #[cfg(feature = "custom")] use crate::custom::{CustomData, Payload}; -pub fn parse(input: &str) -> TextComponent { +pub(super) fn parse(input: &str) -> TextComponent { parse_raw(input).into_owned() } -pub fn parse_raw<'a>(input: &'a str) -> RawTextComponent<'a> { + +pub(super) fn parse_raw<'a>(input: &'a str) -> RawTextComponent<'a> { Parser::parse(input) } @@ -43,14 +44,14 @@ fn join_with_colon<'a>(args: &[Cow<'a, str>]) -> Cow<'a, str> { Cow::Owned(result) } -struct Parser<'a> { +pub(super) struct Parser<'a> { nodes: Vec>, children: Vec>, stack: Vec<(usize, Cow<'a, str>)>, } impl<'a> Parser<'a> { - fn parse(input: &'a str) -> RawTextComponent<'a> { + pub fn parse(input: &'a str) -> RawTextComponent<'a> { let mut parser = Parser { nodes: vec![RawTextComponent::new()], children: vec![Vec::new()], From f90c341f535d7d3eb6240a53684860a14a85146e Mon Sep 17 00:00:00 2001 From: suprohub Date: Sat, 9 May 2026 23:35:35 +0300 Subject: [PATCH 09/15] Syntax sugar --- src/content.rs | 14 +++++++------- src/format.rs | 41 ++++++++++++++++++++++------------------- src/interactivity.rs | 24 ++++++++++++------------ src/lib.rs | 23 ++++++++++------------- src/nbt.rs | 2 +- src/translation.rs | 5 +---- 6 files changed, 53 insertions(+), 56 deletions(-) diff --git a/src/content.rs b/src/content.rs index 74d1c45..c8824f2 100644 --- a/src/content.rs +++ b/src/content.rs @@ -81,7 +81,7 @@ pub struct ObjectPlayer<'a> { } 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, @@ -99,7 +99,7 @@ impl<'a> ObjectPlayer<'a> { } } /// 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, @@ -110,9 +110,9 @@ impl<'a> ObjectPlayer<'a> { /// 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, @@ -213,7 +213,7 @@ pub enum NbtSource<'a> { } 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. @@ -221,7 +221,7 @@ impl<'a> NbtSource<'a> { 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()) } } diff --git a/src/format.rs b/src/format.rs index 0f9299e..2175f9d 100644 --- a/src/format.rs +++ b/src/format.rs @@ -85,7 +85,7 @@ impl<'a> Format<'a> { } self } - pub fn font>>(mut self, font: F) -> Self { + pub fn font(mut self, font: impl Into>) -> Self { self.font = Some(font.into()); self } @@ -213,28 +213,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 a9be36e..0aaa7d3 100644 --- a/src/interactivity.rs +++ b/src/interactivity.rs @@ -92,17 +92,17 @@ pub enum ClickEvent<'a> { } 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(), } @@ -112,14 +112,14 @@ impl<'a> ClickEvent<'a> { 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(), } @@ -160,7 +160,7 @@ pub enum HoverEvent<'a> { } 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()), } @@ -169,10 +169,10 @@ impl<'a> HoverEvent<'a> { /// * `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(), @@ -184,10 +184,10 @@ impl<'a> HoverEvent<'a> { /// * `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 26a29e8..4c611d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -148,7 +148,7 @@ impl<'a> RawTextComponent<'a> { /// ``` /// let component: TextComponent = "Test Component".into(); /// ``` - pub fn plain>>(text: T) -> Self { + pub fn plain(text: impl Into>) -> Self { RawTextComponent { content: Content::Text { text: text.into() }, children: vec![], @@ -195,10 +195,7 @@ impl<'a> RawTextComponent<'a> { /// // Displays the Diamond Sword sprite /// TextComponent::atlas("item/diamond_sword", Some("minecraft:items")); /// ``` - pub fn atlas>, R: Into>>( - sprite: T, - atlas: Option, - ) -> Self { + pub fn atlas(sprite: impl Into>, atlas: Option>>) -> Self { RawTextComponent { content: Content::Object(Object::Atlas { atlas: atlas.map(Into::into), @@ -236,9 +233,9 @@ impl<'a> RawTextComponent<'a> { /// 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 { RawTextComponent { content: Content::Resolvable(Resolvable::Scoreboard { @@ -260,7 +257,7 @@ impl<'a> RawTextComponent<'a> { /// TextComponent::entity("@a", Some(" ".into())); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn entity>>(selector: T, separator: Option) -> Self { + pub fn entity(selector: impl Into>, separator: Option) -> Self { RawTextComponent { content: Content::Resolvable(Resolvable::Entity { selector: selector.into(), @@ -286,8 +283,8 @@ impl<'a> RawTextComponent<'a> { /// TextComponent::nbt("Health", NbtSource::entity("@p"), false, None); /// ``` /// #### Needs [resolution](TextComponent::resolve) - pub fn nbt>>( - path: T, + pub fn nbt( + path: impl Into>, source: NbtSource<'a>, interpret: bool, separator: Option, @@ -309,7 +306,7 @@ impl<'a> RawTextComponent<'a> { } #[cfg(feature = "custom")] - pub fn custom + 'a>(content: T) -> RawTextComponent<'a> { + pub fn custom(content: impl CustomContent<'a> + 'a) -> RawTextComponent<'a> { RawTextComponent { content: Content::Custom(content.as_data()), children: vec![], @@ -319,7 +316,7 @@ impl<'a> RawTextComponent<'a> { } #[cfg(feature = "minimessage")] - pub fn minimessage>(text: T) -> RawTextComponent<'a> { + pub fn minimessage(text: impl Into<&'a str>) -> RawTextComponent<'a> { use crate::minimessage::Parser; Parser::parse(text.into()) } diff --git a/src/nbt.rs b/src/nbt.rs index aa3c058..fb1b1b1 100644 --- a/src/nbt.rs +++ b/src/nbt.rs @@ -47,7 +47,7 @@ impl<'a> BuildTarget<'a> for NbtBuilder { } impl<'a> RawTextComponent<'a> { - pub fn nbt_display>(tag: T) -> Self { + pub fn nbt_display(tag: impl Into) -> Self { let tag = tag.into(); match tag { NbtTag::Byte(n) => n diff --git a/src/translation.rs b/src/translation.rs index 9d8dc83..1b71398 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -36,10 +36,7 @@ impl<'a> TranslatedMessage<'a> { RawTextComponent::translated(self) } #[inline] - pub fn component_fallback>>( - mut self, - fallback: F, - ) -> RawTextComponent<'a> { + pub fn component_fallback(mut self, fallback: impl Into>) -> RawTextComponent<'a> { self.fallback = Some(fallback.into()); RawTextComponent::translated(self) } From 7701dfa5aab084fee68d8c598106fac47e2cc54b Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 12:46:40 +0300 Subject: [PATCH 10/15] remove all minimessage stuff --- Cargo.toml | 1 - src/lib.rs | 11 - src/minimessage.rs | 834 --------------------------------------- src/minimessage_tests.rs | 793 ------------------------------------- 4 files changed, 1639 deletions(-) delete mode 100644 src/minimessage.rs delete mode 100644 src/minimessage_tests.rs diff --git a/Cargo.toml b/Cargo.toml index acea534..d6622cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ custom = [] nbt = ["dep:simdnbt"] serde = ["dep:serde"] ownable = ["dep:ownable"] -minimessage = ["ownable"] build = [ "dep:heck", "dep:proc-macro2", diff --git a/src/lib.rs b/src/lib.rs index 4c611d5..c295735 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,11 +16,6 @@ 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; @@ -314,12 +309,6 @@ 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 deleted file mode 100644 index 1b1ae6d..0000000 --- a/src/minimessage.rs +++ /dev/null @@ -1,834 +0,0 @@ -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 deleted file mode 100644 index 65fbb32..0000000 --- a/src/minimessage_tests.rs +++ /dev/null @@ -1,793 +0,0 @@ -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"), - } -} From b19b19b3447c71be82741b69f17732f8ac444ff5 Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 15:04:37 +0300 Subject: [PATCH 11/15] Add databake --- Cargo.lock | 105 ++++++++++++++++++++++++++++--------------- Cargo.toml | 2 + src/content.rs | 12 +++++ src/custom.rs | 4 ++ src/format.rs | 4 ++ src/interactivity.rs | 6 +++ src/lib.rs | 4 ++ src/translation.rs | 2 + 8 files changed, 103 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c1fab0a..f3ff040 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.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +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.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +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", @@ -155,6 +155,28 @@ dependencies = [ "syn", ] +[[package]] +name = "databake" +version = "0.3.0" +source = "git+https://github.com/suprohub/databake#bbf2d2ac819d4150f56c70fd74415a3d465aa8d1" +dependencies = [ + "databake-derive", + "proc-macro2", + "quote", + "uuid", +] + +[[package]] +name = "databake-derive" +version = "0.3.0" +source = "git+https://github.com/suprohub/databake#bbf2d2ac819d4150f56c70fd74415a3d465aa8d1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -238,9 +260,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -291,7 +313,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -304,13 +326,12 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -328,15 +349,15 @@ 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" @@ -507,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", @@ -520,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" @@ -579,9 +600,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "strsim" @@ -606,12 +627,24 @@ 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", @@ -661,9 +694,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom", "js-sys", @@ -691,9 +724,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -704,9 +737,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -714,9 +747,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -727,9 +760,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index d6622cf..e21d663 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ custom = [] nbt = ["dep:simdnbt"] serde = ["dep:serde"] ownable = ["dep:ownable"] +databake = ["dep:databake"] build = [ "dep:heck", "dep:proc-macro2", @@ -50,6 +51,7 @@ 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" diff --git a/src/content.rs b/src/content.rs index c8824f2..d73204a 100644 --- a/src/content.rs +++ b/src/content.rs @@ -6,6 +6,8 @@ use crate::{ 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))] @@ -34,6 +36,8 @@ impl<'a> From for Content<'a> { } #[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<'a> { @@ -55,6 +59,8 @@ pub enum Object<'a> { }, } #[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<'a> { @@ -134,6 +140,8 @@ impl<'a> ObjectPlayer<'a> { } #[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<'a> { @@ -143,6 +151,8 @@ pub struct PlayerProperties<'a> { } #[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<'a> { @@ -203,6 +213,8 @@ impl<'a> Resolvable<'a> { } #[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"))] diff --git a/src/custom.rs b/src/custom.rs index cc80de8..5fd448c 100644 --- a/src/custom.rs +++ b/src/custom.rs @@ -2,6 +2,8 @@ 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<'a> { @@ -14,6 +16,8 @@ pub struct CustomData<'a> { } #[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 { diff --git a/src/format.rs b/src/format.rs index 2175f9d..c2d77cf 100644 --- a/src/format.rs +++ b/src/format.rs @@ -2,6 +2,8 @@ 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<'a> { @@ -174,6 +176,8 @@ impl<'a> Format<'a> { } #[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"))] diff --git a/src/interactivity.rs b/src/interactivity.rs index 0aaa7d3..a1dd6e6 100644 --- a/src/interactivity.rs +++ b/src/interactivity.rs @@ -6,6 +6,8 @@ use crate::custom::CustomData; use std::borrow::Cow; #[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<'a> { @@ -65,6 +67,8 @@ impl<'a> Interactivity<'a> { } #[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"))] @@ -127,6 +131,8 @@ impl<'a> ClickEvent<'a> { } #[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"))] diff --git a/src/lib.rs b/src/lib.rs index c295735..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,6 +86,8 @@ 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 RawTextComponent<'a> { diff --git a/src/translation.rs b/src/translation.rs index 1b71398..08d735f 100644 --- a/src/translation.rs +++ b/src/translation.rs @@ -2,6 +2,8 @@ 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<'a> { From ca31aa56d299760c229280c9e068d73346a5818d Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 15:19:07 +0300 Subject: [PATCH 12/15] Update ver --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f3ff040..a52e124 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,7 +158,7 @@ dependencies = [ [[package]] name = "databake" version = "0.3.0" -source = "git+https://github.com/suprohub/databake#bbf2d2ac819d4150f56c70fd74415a3d465aa8d1" +source = "git+https://github.com/suprohub/databake#37cb31e7d616bef163d39d8412be71bc33e2950f" dependencies = [ "databake-derive", "proc-macro2", @@ -169,7 +169,7 @@ dependencies = [ [[package]] name = "databake-derive" version = "0.3.0" -source = "git+https://github.com/suprohub/databake#bbf2d2ac819d4150f56c70fd74415a3d465aa8d1" +source = "git+https://github.com/suprohub/databake#37cb31e7d616bef163d39d8412be71bc33e2950f" dependencies = [ "proc-macro2", "quote", From d4aa66251b4d7708057df0b8db9685740313afd4 Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 15:22:58 +0300 Subject: [PATCH 13/15] No minimasaga --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 52c6a02..cb96e79 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ println!("{:p}", component); - [x] Terminal integration - [x] Serde integration - [x] SimdNbt integration -- [x] MiniMessage integration +- [ ] MiniMessage integration - [ ] Extensibility integration ### Test From b2192951379802d77cc6e963dec6ef39d24c5846 Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 16:40:53 +0300 Subject: [PATCH 14/15] Docs --- src/interactivity.rs | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/interactivity.rs b/src/interactivity.rs index a1dd6e6..e5b3866 100644 --- a/src/interactivity.rs +++ b/src/interactivity.rs @@ -5,17 +5,22 @@ use crate::RawTextComponent; 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<'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>, + + /// The action performed when the player clicks on this component. #[cfg_attr( feature = "serde", serde( @@ -25,6 +30,8 @@ pub struct Interactivity<'a> { ) )] pub click: Option>, + + /// The action performed when the player hovers over this component. #[cfg_attr( feature = "serde", serde( @@ -50,9 +57,11 @@ impl<'a> Interactivity<'a> { 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() @@ -66,6 +75,7 @@ impl<'a> Interactivity<'a> { } } +/// 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))] @@ -73,54 +83,73 @@ impl<'a> Interactivity<'a> { #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "action", rename_all = "snake_case"))] pub enum ClickEvent<'a> { + /// Opens the given URL. OpenUrl { + /// The URL to open. url: Cow<'a, str>, }, + /// Runs a command as the player. RunCommand { + /// The command to execute. command: Cow<'a, str>, }, + /// Replaces the player's chat input with a command (without sending it). SuggestCommand { + /// 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 { + /// The string to copy. value: Cow<'a, str>, }, + /// Shows a custom dialog. ShowDialog { + /// 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<'a>), } + impl<'a> ClickEvent<'a> { /// Creates a [ClickEvent] that opens a url when triggered. 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: 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: 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: 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: impl Into>) -> Self { @@ -130,6 +159,7 @@ impl<'a> ClickEvent<'a> { } } +/// 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))] @@ -137,33 +167,44 @@ impl<'a> ClickEvent<'a> { #[cfg_attr(feature = "serde", derive(::serde::Serialize, ::serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "action", rename_all = "snake_case"))] pub enum HoverEvent<'a> { + /// Displays a text component as a tooltip. ShowText { + /// The text component to display. value: Box>, }, + /// Displays an item tooltip. ShowItem { + /// 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>, }, + /// 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>>, + /// 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<'a> HoverEvent<'a> { /// Creates a [HoverEvent] that will show a text component. pub fn show_text(text: impl Into>) -> Self { @@ -171,6 +212,7 @@ impl<'a> HoverEvent<'a> { 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 @@ -186,6 +228,7 @@ impl<'a> HoverEvent<'a> { 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 From a1e4c043f3c7bc642eeb14f70eac9de36b01cd81 Mon Sep 17 00:00:00 2001 From: suprohub Date: Fri, 12 Jun 2026 16:51:01 +0300 Subject: [PATCH 15/15] Docs again --- src/resolving.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/resolving.rs b/src/resolving.rs index f682135..de26e1f 100644 --- a/src/resolving.rs +++ b/src/resolving.rs @@ -7,15 +7,57 @@ use crate::{ content::{Content, Resolvable}, }; -/// Recommendation: Implement this on the World and Player +/// 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()) } + + /// 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<'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 { @@ -58,7 +100,13 @@ impl<'a, T: TextResolutor<'a>> TextResolutor<'a> 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<'a> TextResolutor<'a> for NoResolutor { fn resolve_content(&self, resolvable: &Resolvable<'a>) -> RawTextComponent<'a> { match resolvable { @@ -83,6 +131,27 @@ impl<'a> TextResolutor<'a> for NoResolutor { } 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, @@ -91,6 +160,15 @@ impl<'a> RawTextComponent<'a> { target.build_component(resolutor, &self.resolve(resolutor)) } + /// 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")] @@ -133,8 +211,21 @@ impl<'a> RawTextComponent<'a> { } } +/// 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; + + /// 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,