1use crate::value::{Ref, Res, Value};
17
18pub const DEFAULT_NAMESPACE: &str = "minecraft";
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 tail.is_empty() || !tail.split('.').all(is_ident) {
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 "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 "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 "image" => {
80 if tail.is_empty() {
81 return Err(format!("`{s}`: no image source after `@image:`"));
82 }
83 Ok(Value::Image(tail.to_string()))
84 }
85 _ => Ok(Value::Ref(Ref {
86 kind: head.to_string(),
87 id: resource_id(s, tail)?,
88 })),
89 }
90}
91
92pub fn typed_string(v: &Value) -> Option<String> {
94 Some(match v {
95 Value::Ref(r) => format!("@{}:{}", r.kind, r.id),
96 Value::Tag(r) => format!("@tag:{}:{}", r.kind, r.id),
97 Value::Res(r) => format!("@{}.{}", r.set, r.key),
98 Value::Image(src) => format!("@image:{src}"),
99 Value::Lang(key) => format!("@lang:{key}"),
100 Value::Color(c) => format_color(*c),
101 Value::Text(t) => escape_at(t),
102 Value::Str(t) => escape_at(t),
103 _ => return None,
104 })
105}
106
107pub fn escape_at(s: &str) -> String {
109 if s.starts_with('@') {
110 format!("@{s}")
111 } else {
112 s.to_string()
113 }
114}
115
116pub fn resource_id(whole: &str, id: &str) -> Result<String, String> {
119 let base_end = id.find(['[', '{']).unwrap_or(id.len());
120 let (base, suffix) = id.split_at(base_end);
121 let (ns, path) = base.split_once(':').unwrap_or((DEFAULT_NAMESPACE, base));
122 let ok_ns = !ns.is_empty()
123 && ns
124 .bytes()
125 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.'));
126 let ok_path = !path.is_empty()
127 && path
128 .bytes()
129 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/'));
130 if !ok_ns || !ok_path {
131 return Err(format!(
132 "`{whole}`: `{base}` isn't an id (lower case `namespace:path`)"
133 ));
134 }
135 if !suffix.is_empty() && !(suffix.ends_with(']') || suffix.ends_with('}')) {
136 return Err(format!("`{whole}`: unclosed `{}`", &suffix[..1]));
137 }
138 Ok(format!("{ns}:{path}{suffix}"))
139}
140
141pub fn parse_color(s: &str) -> Option<u32> {
143 let hex = s.strip_prefix('#')?;
144 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
145 return None;
146 }
147 match hex.len() {
148 6 => u32::from_str_radix(hex, 16).ok().map(|c| 0xFF00_0000 | c),
149 8 => u32::from_str_radix(hex, 16).ok(),
150 _ => None,
151 }
152}
153
154pub fn format_color(c: u32) -> String {
156 if c >> 24 == 0xFF {
157 format!("#{:06X}", c & 0xFF_FFFF)
158 } else {
159 format!("#{c:08X}")
160 }
161}
162
163fn is_ident(s: &str) -> bool {
164 let mut chars = s.chars();
165 chars
166 .next()
167 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
168 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
169}
170
171fn is_kind(s: &str) -> bool {
172 let mut b = s.bytes();
173 b.next().is_some_and(|c| c.is_ascii_lowercase())
174 && b.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn reads_every_form() {
183 let r = |k: &str, id: &str| Ref {
184 kind: k.into(),
185 id: id.into(),
186 };
187 assert_eq!(classify("plain").unwrap(), Value::Str("plain".into()));
188 assert_eq!(classify("@@me").unwrap(), Value::Str("@me".into()));
189 assert_eq!(
190 classify("@item:minecraft:stick").unwrap(),
191 Value::Ref(r("item", "minecraft:stick"))
192 );
193 assert_eq!(
194 classify("@item:stick").unwrap(),
195 Value::Ref(r("item", "minecraft:stick"))
196 );
197 assert_eq!(
198 classify("@block:furnace[facing=north]").unwrap(),
199 Value::Ref(r("block", "minecraft:furnace[facing=north]"))
200 );
201 assert_eq!(
202 classify("@tag:item:minecraft:planks").unwrap(),
203 Value::Tag(r("item", "minecraft:planks"))
204 );
205 assert_eq!(
207 classify("@pattern:hexmod:get_caster").unwrap(),
208 Value::Ref(r("pattern", "hexmod:get_caster"))
209 );
210 }
211
212 #[test]
213 fn resources_and_images() {
214 assert_eq!(
215 classify("@Strings.screen.title").unwrap(),
216 Value::Res(Res {
217 set: "Strings".into(),
218 key: "screen.title".into()
219 })
220 );
221 assert_eq!(
222 classify("@image:sprite:button").unwrap(),
223 Value::Image("sprite:button".into())
224 );
225 }
226
227 #[test]
228 fn rejects_typos() {
229 for bad in [
230 "@",
231 "@stick",
232 "@Item:stick",
233 "@item:Stick",
234 "@item:",
235 "@tag:item",
236 "@strings.title",
237 "@Strings.",
238 "@block:furnace[facing=north",
239 "@image:",
240 ] {
241 assert!(classify(bad).is_err(), "{bad} should fail");
242 }
243 }
244
245 #[test]
246 fn round_trips() {
247 for s in [
248 "@item:minecraft:stick",
249 "@tag:block:minecraft:logs",
250 "@Strings.a.b",
251 "@image:sprite:x",
252 "@@at",
253 "plain",
254 ] {
255 assert_eq!(typed_string(&classify(s).unwrap()).unwrap(), s);
256 }
257 }
258
259 #[test]
260 fn colors() {
261 assert_eq!(parse_color("#8d6acc"), Some(0xFF8D6ACC));
262 assert_eq!(parse_color("#808d6acc"), Some(0x808D6ACC));
263 assert_eq!(parse_color("#fff"), None);
264 assert_eq!(format_color(0xFF8D6ACC), "#8D6ACC");
265 assert_eq!(format_color(0x808D6ACC), "#808D6ACC");
266 }
267}