1use crate::value::{Ref, Res, Value};
16
17pub const DEFAULT_NAMESPACE: &str = "minecraft";
19
20pub fn classify(s: &str) -> Result<Value, String> {
23 let Some(rest) = s.strip_prefix('@') else {
24 return Ok(Value::Str(s.to_string()));
25 };
26 if let Some(literal) = rest.strip_prefix('@') {
27 return Ok(Value::Str(format!("@{literal}")));
28 }
29 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 `@`"))?;
30 let head = &rest[..head_end];
31 let tail = &rest[head_end + 1..];
32 if rest.as_bytes()[head_end] == b'.' {
33 if !is_ident(head) || !head.starts_with(|c: char| c.is_ascii_uppercase()) {
34 return Err(format!(
35 "`{s}`: a resource is `@Set.key` with an upper-case set name (`@Strings.title`)"
36 ));
37 }
38 if tail.is_empty() || !tail.split('.').all(is_ident) {
39 return Err(format!(
40 "`{s}`: `{tail}` isn't a resource key (letters, digits, `_`, dotted)"
41 ));
42 }
43 return Ok(Value::Res(Res {
44 set: head.to_string(),
45 key: tail.to_string(),
46 }));
47 }
48 if !is_kind(head) {
49 return Err(format!(
50 "`{s}`: `{head}` isn't a kind of content (lower case: `item`, `block`, `tag`, ...)"
51 ));
52 }
53 match head {
54 "tag" => {
55 let (kind, id) = tail.split_once(':').ok_or_else(|| {
56 format!("`{s}`: a tag is `@tag:kind:id` (`@tag:item:minecraft:planks`)")
57 })?;
58 if !is_kind(kind) {
59 return Err(format!("`{s}`: `{kind}` isn't a kind of content"));
60 }
61 Ok(Value::Tag(Ref {
62 kind: kind.to_string(),
63 id: resource_id(s, id)?,
64 }))
65 }
66 "image" => {
67 if tail.is_empty() {
68 return Err(format!("`{s}`: no image source after `@image:`"));
69 }
70 Ok(Value::Image(tail.to_string()))
71 }
72 _ => Ok(Value::Ref(Ref {
73 kind: head.to_string(),
74 id: resource_id(s, tail)?,
75 })),
76 }
77}
78
79pub fn typed_string(v: &Value) -> Option<String> {
81 Some(match v {
82 Value::Ref(r) => format!("@{}:{}", r.kind, r.id),
83 Value::Tag(r) => format!("@tag:{}:{}", r.kind, r.id),
84 Value::Res(r) => format!("@{}.{}", r.set, r.key),
85 Value::Image(src) => format!("@image:{src}"),
86 Value::Color(c) => format_color(*c),
87 Value::Text(t) => escape_at(t),
88 Value::Str(t) => escape_at(t),
89 _ => return None,
90 })
91}
92
93pub fn escape_at(s: &str) -> String {
95 if s.starts_with('@') {
96 format!("@{s}")
97 } else {
98 s.to_string()
99 }
100}
101
102pub fn resource_id(whole: &str, id: &str) -> Result<String, String> {
105 let base_end = id.find(['[', '{']).unwrap_or(id.len());
106 let (base, suffix) = id.split_at(base_end);
107 let (ns, path) = base.split_once(':').unwrap_or((DEFAULT_NAMESPACE, base));
108 let ok_ns = !ns.is_empty()
109 && ns
110 .bytes()
111 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.'));
112 let ok_path = !path.is_empty()
113 && path
114 .bytes()
115 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/'));
116 if !ok_ns || !ok_path {
117 return Err(format!(
118 "`{whole}`: `{base}` isn't an id (lower case `namespace:path`)"
119 ));
120 }
121 if !suffix.is_empty() && !(suffix.ends_with(']') || suffix.ends_with('}')) {
122 return Err(format!("`{whole}`: unclosed `{}`", &suffix[..1]));
123 }
124 Ok(format!("{ns}:{path}{suffix}"))
125}
126
127pub fn parse_color(s: &str) -> Option<u32> {
129 let hex = s.strip_prefix('#')?;
130 if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
131 return None;
132 }
133 match hex.len() {
134 6 => u32::from_str_radix(hex, 16).ok().map(|c| 0xFF00_0000 | c),
135 8 => u32::from_str_radix(hex, 16).ok(),
136 _ => None,
137 }
138}
139
140pub fn format_color(c: u32) -> String {
142 if c >> 24 == 0xFF {
143 format!("#{:06X}", c & 0xFF_FFFF)
144 } else {
145 format!("#{c:08X}")
146 }
147}
148
149fn is_ident(s: &str) -> bool {
150 let mut chars = s.chars();
151 chars
152 .next()
153 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
154 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
155}
156
157fn is_kind(s: &str) -> bool {
158 let mut b = s.bytes();
159 b.next().is_some_and(|c| c.is_ascii_lowercase())
160 && b.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn reads_every_form() {
169 let r = |k: &str, id: &str| Ref {
170 kind: k.into(),
171 id: id.into(),
172 };
173 assert_eq!(classify("plain").unwrap(), Value::Str("plain".into()));
174 assert_eq!(classify("@@me").unwrap(), Value::Str("@me".into()));
175 assert_eq!(
176 classify("@item:minecraft:stick").unwrap(),
177 Value::Ref(r("item", "minecraft:stick"))
178 );
179 assert_eq!(
180 classify("@item:stick").unwrap(),
181 Value::Ref(r("item", "minecraft:stick"))
182 );
183 assert_eq!(
184 classify("@block:furnace[facing=north]").unwrap(),
185 Value::Ref(r("block", "minecraft:furnace[facing=north]"))
186 );
187 assert_eq!(
188 classify("@tag:item:minecraft:planks").unwrap(),
189 Value::Tag(r("item", "minecraft:planks"))
190 );
191 assert_eq!(
193 classify("@pattern:hexmod:get_caster").unwrap(),
194 Value::Ref(r("pattern", "hexmod:get_caster"))
195 );
196 }
197
198 #[test]
199 fn resources_and_images() {
200 assert_eq!(
201 classify("@Strings.screen.title").unwrap(),
202 Value::Res(Res {
203 set: "Strings".into(),
204 key: "screen.title".into()
205 })
206 );
207 assert_eq!(
208 classify("@image:sprite:button").unwrap(),
209 Value::Image("sprite:button".into())
210 );
211 }
212
213 #[test]
214 fn rejects_typos() {
215 for bad in [
216 "@",
217 "@stick",
218 "@Item:stick",
219 "@item:Stick",
220 "@item:",
221 "@tag:item",
222 "@strings.title",
223 "@Strings.",
224 "@block:furnace[facing=north",
225 "@image:",
226 ] {
227 assert!(classify(bad).is_err(), "{bad} should fail");
228 }
229 }
230
231 #[test]
232 fn round_trips() {
233 for s in [
234 "@item:minecraft:stick",
235 "@tag:block:minecraft:logs",
236 "@Strings.a.b",
237 "@image:sprite:x",
238 "@@at",
239 "plain",
240 ] {
241 assert_eq!(typed_string(&classify(s).unwrap()).unwrap(), s);
242 }
243 }
244
245 #[test]
246 fn colors() {
247 assert_eq!(parse_color("#8d6acc"), Some(0xFF8D6ACC));
248 assert_eq!(parse_color("#808d6acc"), Some(0x808D6ACC));
249 assert_eq!(parse_color("#fff"), None);
250 assert_eq!(format_color(0xFF8D6ACC), "#8D6ACC");
251 assert_eq!(format_color(0x808D6ACC), "#808D6ACC");
252 }
253}