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//! | `@lang:gui.done` (a key as in the lang files) | [`Value::Lang`] |
10//! | `#RRGGBB`, `#AARRGGBB` | [`Value::Color`] |
11//! | `@@text` | the plain string `@text` |
12//!
13//! A string that starts with `@` is always one of these (or an error), in
14//! any field — so a typo in a reference is never silently plain text.
15
16use crate::value::{Ref, Res, Value};
17
18/// Ids without a namespace are vanilla's, as in Minecraft.
19pub const DEFAULT_NAMESPACE: &str = "minecraft";
20
21/// `s` as a typed value if it's written as one (`@…`), the plain string
22/// otherwise; `@@` escapes a leading `@`.
23pub 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
92/// The string form of a typed value (`None` for the untyped ones).
93pub 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
107/// `s` with a leading `@` doubled, so it reads back as plain text.
108pub fn escape_at(s: &str) -> String {
109    if s.starts_with('@') {
110        format!("@{s}")
111    } else {
112        s.to_string()
113    }
114}
115
116/// A content id: `namespace:path` (namespace defaults to `minecraft`),
117/// lower case, with an optional `[state]` / `{nbt}` suffix kept as written.
118pub 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
141/// `#RRGGBB` (opaque) or `#AARRGGBB` → `0xAARRGGBB`.
142pub 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
154/// `0xAARRGGBB` → `#RRGGBB` when opaque, else `#AARRGGBB`.
155pub 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        // A mod's own kind of content.
206        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}