1use crate::model::value::{Ref, Res, Value};
17
18use base64::Engine as _;
19use yog_ids::typed;
20
21pub fn classify(s: &str) -> Result<Value, String> {
24 let Some(rest) = s.strip_prefix('@') else {
25 return Ok(Value::Str(s.to_string()));
26 };
27 if let Some(literal) = rest.strip_prefix('@') {
28 return Ok(Value::Str(format!("@{literal}")));
29 }
30 let head_end = rest.find([':', '.']).ok_or_else(|| format!("`{s}`: a reference is `@kind:id` (`@item:minecraft:stick`) or `@Set.key`; write `@@` for a literal `@`"))?;
31 let head = &rest[..head_end];
32 let tail = &rest[head_end + 1..];
33 if rest.as_bytes()[head_end] == b'.' {
34 if !is_ident(head) || !head.starts_with(|c: char| c.is_ascii_uppercase()) {
35 return Err(format!(
36 "`{s}`: a resource is `@Set.key` with an upper-case set name (`@Strings.title`)"
37 ));
38 }
39 if !is_res_key(tail) {
40 return Err(format!(
41 "`{s}`: `{tail}` isn't a resource key (letters, digits, `_`, dotted)"
42 ));
43 }
44 return Ok(Value::Res(Res {
45 set: head.to_string(),
46 key: tail.to_string(),
47 }));
48 }
49 if !is_kind(head) {
50 return Err(format!(
51 "`{s}`: `{head}` isn't a kind of content (lower case: `item`, `block`, `tag`, ...)"
52 ));
53 }
54 match head {
55 typed::TAG => {
56 let (kind, id) = tail.split_once(':').ok_or_else(|| {
57 format!("`{s}`: a tag is `@tag:kind:id` (`@tag:item:minecraft:planks`)")
58 })?;
59 if !is_kind(kind) {
60 return Err(format!("`{s}`: `{kind}` isn't a kind of content"));
61 }
62 Ok(Value::Tag(Ref {
63 kind: kind.to_string(),
64 id: resource_id(s, id)?,
65 }))
66 }
67 typed::LANG => {
68 if tail.is_empty()
69 || !tail.bytes().all(|b| {
70 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b':' | b'/')
71 })
72 {
73 return Err(format!(
74 "`{s}`: `@lang:` takes a translation key as the lang files have it (`@lang:gui.done`)"
75 ));
76 }
77 Ok(Value::Lang(tail.to_string()))
78 }
79 typed::BYTES => base64::engine::general_purpose::STANDARD
80 .decode(tail)
81 .map(Value::Bytes)
82 .map_err(|e| format!("`@bytes:`: not base64 ({e})")),
83 typed::IMAGE => {
84 if tail.is_empty() {
85 return Err(format!("`{s}`: no image source after `@image:`"));
86 }
87 Ok(Value::Image(tail.to_string()))
88 }
89 _ => Ok(Value::Ref(Ref {
90 kind: head.to_string(),
91 id: resource_id(s, tail)?,
92 })),
93 }
94}
95
96pub fn typed_string(v: &Value) -> Option<String> {
98 Some(match v {
99 Value::Ref(r) => format!("@{}:{}", r.kind, r.id),
100 Value::Tag(r) => format!("@tag:{}:{}", r.kind, r.id),
101 Value::Res(r) => r.to_string(),
102 Value::Image(src) => format!("@image:{src}"),
103 Value::Lang(key) => format!("@lang:{key}"),
104 Value::Color(c) => format_color(*c),
105 Value::Bytes(b) => format!(
106 "@{}:{}",
107 typed::BYTES,
108 base64::engine::general_purpose::STANDARD.encode(b)
109 ),
110 Value::Text(t) => escape_at(t),
111 Value::Str(t) => escape_at(t),
112 _ => return None,
113 })
114}
115
116pub fn flatten(v: &Value) -> Value {
119 match v {
120 Value::List(items) => Value::List(items.iter().map(flatten).collect()),
121 Value::Map(m) => Value::Map(m.iter().map(|(k, x)| (k.to_string(), flatten(x))).collect()),
122 Value::Str(_) => v.clone(),
123 typed => typed_string(typed).map_or_else(|| typed.clone(), Value::Str),
124 }
125}
126
127pub fn escape_at(s: &str) -> String {
129 if s.starts_with('@') {
130 format!("@{s}")
131 } else {
132 s.to_string()
133 }
134}
135
136pub fn resource_id(whole: &str, id: &str) -> Result<String, String> {
139 let base_end = id.find(['[', '{']).unwrap_or(id.len());
140 let (base, suffix) = id.split_at(base_end);
141 let (ns, path) = yog_ids::split(base);
142 let ok_ns = !ns.is_empty()
143 && ns
144 .bytes()
145 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.'));
146 let ok_path = !path.is_empty()
147 && path
148 .bytes()
149 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/'));
150 if !ok_ns || !ok_path {
151 return Err(format!(
152 "`{whole}`: `{base}` isn't an id (lower case `namespace:path`)"
153 ));
154 }
155 if !suffix.is_empty() && !(suffix.ends_with(']') || suffix.ends_with('}')) {
156 return Err(format!("`{whole}`: unclosed `{}`", &suffix[..1]));
157 }
158 Ok(format!("{ns}:{path}{suffix}"))
159}
160
161const RGB_DIGITS: usize = 6;
163const ARGB_DIGITS: usize = 8;
164const HEX_RADIX: u32 = 16;
165const OPAQUE: u32 = 0xFF00_0000;
166
167pub fn parse_color(s: &str) -> Option<u32> {
169 let hex = s.strip_prefix('#')?;
170 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
171 return None;
172 }
173 match hex.len() {
174 RGB_DIGITS => u32::from_str_radix(hex, HEX_RADIX).ok().map(|c| OPAQUE | c),
175 ARGB_DIGITS => u32::from_str_radix(hex, HEX_RADIX).ok(),
176 _ => None,
177 }
178}
179
180pub fn format_color(c: u32) -> String {
182 if c >> 24 == 0xFF {
183 format!("#{:06X}", c & 0xFF_FFFF)
184 } else {
185 format!("#{c:08X}")
186 }
187}
188
189pub fn is_res_key(key: &str) -> bool {
192 let mut parts = key.split('.');
193 parts.next().is_some_and(is_ident)
194 && parts.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_'))
195}
196
197fn is_ident(s: &str) -> bool {
198 let mut chars = s.chars();
199 chars
200 .next()
201 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
202 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
203}
204
205fn is_kind(s: &str) -> bool {
206 let mut b = s.bytes();
207 b.next().is_some_and(|c| c.is_ascii_lowercase())
208 && b.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn reads_every_form() {
217 let r = |k: &str, id: &str| Ref {
218 kind: k.into(),
219 id: id.into(),
220 };
221 assert_eq!(classify("plain").unwrap(), Value::Str("plain".into()));
222 assert_eq!(classify("@@me").unwrap(), Value::Str("@me".into()));
223 assert_eq!(
224 classify("@item:minecraft:stick").unwrap(),
225 Value::Ref(r("item", "minecraft:stick"))
226 );
227 assert_eq!(
228 classify("@item:stick").unwrap(),
229 Value::Ref(r("item", "minecraft:stick"))
230 );
231 assert_eq!(
232 classify("@block:furnace[facing=north]").unwrap(),
233 Value::Ref(r("block", "minecraft:furnace[facing=north]"))
234 );
235 assert_eq!(
236 classify("@tag:item:minecraft:planks").unwrap(),
237 Value::Tag(r("item", "minecraft:planks"))
238 );
239 assert_eq!(
241 classify("@pattern:hexmod:get_caster").unwrap(),
242 Value::Ref(r("pattern", "hexmod:get_caster"))
243 );
244 }
245
246 #[test]
247 fn resources_and_images() {
248 assert_eq!(
249 classify("@Strings.screen.title").unwrap(),
250 Value::Res(Res {
251 set: "Strings".into(),
252 key: "screen.title".into()
253 })
254 );
255 assert_eq!(
256 classify("@image:sprite:button").unwrap(),
257 Value::Image("sprite:button".into())
258 );
259 }
260
261 #[test]
262 fn rejects_typos() {
263 for bad in [
264 "@",
265 "@stick",
266 "@Item:stick",
267 "@item:Stick",
268 "@item:",
269 "@tag:item",
270 "@strings.title",
271 "@Strings.",
272 "@block:furnace[facing=north",
273 "@image:",
274 ] {
275 assert!(classify(bad).is_err(), "{bad} should fail");
276 }
277 }
278
279 #[test]
280 fn round_trips() {
281 for s in [
282 "@item:minecraft:stick",
283 "@tag:block:minecraft:logs",
284 "@Strings.a.b",
285 "@image:sprite:x",
286 "@@at",
287 "plain",
288 ] {
289 assert_eq!(typed_string(&classify(s).unwrap()).unwrap(), s);
290 }
291 }
292
293 #[test]
294 fn colors() {
295 assert_eq!(parse_color("#8d6acc"), Some(0xFF8D6ACC));
296 assert_eq!(parse_color("#808d6acc"), Some(0x808D6ACC));
297 assert_eq!(parse_color("#fff"), None);
298 assert_eq!(format_color(0xFF8D6ACC), "#8D6ACC");
299 assert_eq!(format_color(0x808D6ACC), "#808D6ACC");
300 }
301}