Skip to main content

yog_data/
typed.rs

1//! The typed values' string forms — one grammar for every format:
2//!
3//! | Written | Value |
4//! |---|---|
5//! | `@item:minecraft:stick`, `@block:minecraft:furnace[facing=north]` | [`Value::Ref`] |
6//! | `@tag:item:minecraft:planks` | [`Value::Tag`] |
7//! | `@Strings.screen.title` (set names start upper case) | [`Value::Res`] |
8//! | `@image:sprite:button`, `@image:mymod:gui/logo` | [`Value::Image`] |
9//! | `#RRGGBB`, `#AARRGGBB` | [`Value::Color`] |
10//! | `@@text` | the plain string `@text` |
11//!
12//! A string that starts with `@` is always one of these (or an error), in
13//! any field — so a typo in a reference is never silently plain text.
14
15use crate::value::{Ref, Res, Value};
16
17/// Ids without a namespace are vanilla's, as in Minecraft.
18pub const DEFAULT_NAMESPACE: &str = "minecraft";
19
20/// `s` as a typed value if it's written as one (`@…`), the plain string
21/// otherwise; `@@` escapes a leading `@`.
22pub 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
79/// The string form of a typed value (`None` for the untyped ones).
80pub 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
93/// `s` with a leading `@` doubled, so it reads back as plain text.
94pub fn escape_at(s: &str) -> String {
95    if s.starts_with('@') {
96        format!("@{s}")
97    } else {
98        s.to_string()
99    }
100}
101
102/// A content id: `namespace:path` (namespace defaults to `minecraft`),
103/// lower case, with an optional `[state]` / `{nbt}` suffix kept as written.
104pub 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
127/// `#RRGGBB` (opaque) or `#AARRGGBB` → `0xAARRGGBB`.
128pub 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
140/// `0xAARRGGBB` → `#RRGGBB` when opaque, else `#AARRGGBB`.
141pub 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        // A mod's own kind of content.
192        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}