Skip to main content

yog_data/model/
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::model::value::{Ref, Res, Value};
17
18use base64::Engine as _;
19use yog_ids::typed;
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 !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
96/// The string form of a typed value (`None` for the untyped ones).
97pub 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
116/// `v` with its typed values as their strings (`@item:x`): plain values
117/// only, for a tool that knows no typed ones (the IDE, through `yog serve`).
118pub 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
127/// `s` with a leading `@` doubled, so it reads back as plain text.
128pub fn escape_at(s: &str) -> String {
129    if s.starts_with('@') {
130        format!("@{s}")
131    } else {
132        s.to_string()
133    }
134}
135
136/// A content id: `namespace:path` (namespace defaults to `minecraft`),
137/// lower case, with an optional `[state]` / `{nbt}` suffix kept as written.
138pub 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
161/// A colour's digits: `RRGGBB` and `AARRGGBB`, hexadecimal; full alpha.
162const RGB_DIGITS: usize = 6;
163const ARGB_DIGITS: usize = 8;
164const HEX_RADIX: u32 = 16;
165const OPAQUE: u32 = 0xFF00_0000;
166
167/// `#RRGGBB` (opaque) or `#AARRGGBB` → `0xAARRGGBB`.
168pub 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
180/// `0xAARRGGBB` → `#RRGGBB` when opaque, else `#AARRGGBB`.
181pub 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
189/// A resource key: identifiers joined by `.` (`screen.title`); after the
190/// first, a part may start with a digit (`item.robes.2_boots`).
191pub 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        // A mod's own kind of content.
240        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}