use crate::value::{Ref, Res, Value};
pub const DEFAULT_NAMESPACE: &str = "minecraft";
pub fn classify(s: &str) -> Result<Value, String> {
let Some(rest) = s.strip_prefix('@') else {
return Ok(Value::Str(s.to_string()));
};
if let Some(literal) = rest.strip_prefix('@') {
return Ok(Value::Str(format!("@{literal}")));
}
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 `@`"))?;
let head = &rest[..head_end];
let tail = &rest[head_end + 1..];
if rest.as_bytes()[head_end] == b'.' {
if !is_ident(head) || !head.starts_with(|c: char| c.is_ascii_uppercase()) {
return Err(format!(
"`{s}`: a resource is `@Set.key` with an upper-case set name (`@Strings.title`)"
));
}
if tail.is_empty() || !tail.split('.').all(is_ident) {
return Err(format!(
"`{s}`: `{tail}` isn't a resource key (letters, digits, `_`, dotted)"
));
}
return Ok(Value::Res(Res {
set: head.to_string(),
key: tail.to_string(),
}));
}
if !is_kind(head) {
return Err(format!(
"`{s}`: `{head}` isn't a kind of content (lower case: `item`, `block`, `tag`, ...)"
));
}
match head {
"tag" => {
let (kind, id) = tail.split_once(':').ok_or_else(|| {
format!("`{s}`: a tag is `@tag:kind:id` (`@tag:item:minecraft:planks`)")
})?;
if !is_kind(kind) {
return Err(format!("`{s}`: `{kind}` isn't a kind of content"));
}
Ok(Value::Tag(Ref {
kind: kind.to_string(),
id: resource_id(s, id)?,
}))
}
"lang" => {
if tail.is_empty()
|| !tail.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-' | b':' | b'/')
})
{
return Err(format!(
"`{s}`: `@lang:` takes a translation key as the lang files have it (`@lang:gui.done`)"
));
}
Ok(Value::Lang(tail.to_string()))
}
"image" => {
if tail.is_empty() {
return Err(format!("`{s}`: no image source after `@image:`"));
}
Ok(Value::Image(tail.to_string()))
}
_ => Ok(Value::Ref(Ref {
kind: head.to_string(),
id: resource_id(s, tail)?,
})),
}
}
pub fn typed_string(v: &Value) -> Option<String> {
Some(match v {
Value::Ref(r) => format!("@{}:{}", r.kind, r.id),
Value::Tag(r) => format!("@tag:{}:{}", r.kind, r.id),
Value::Res(r) => format!("@{}.{}", r.set, r.key),
Value::Image(src) => format!("@image:{src}"),
Value::Lang(key) => format!("@lang:{key}"),
Value::Color(c) => format_color(*c),
Value::Text(t) => escape_at(t),
Value::Str(t) => escape_at(t),
_ => return None,
})
}
pub fn escape_at(s: &str) -> String {
if s.starts_with('@') {
format!("@{s}")
} else {
s.to_string()
}
}
pub fn resource_id(whole: &str, id: &str) -> Result<String, String> {
let base_end = id.find(['[', '{']).unwrap_or(id.len());
let (base, suffix) = id.split_at(base_end);
let (ns, path) = base.split_once(':').unwrap_or((DEFAULT_NAMESPACE, base));
let ok_ns = !ns.is_empty()
&& ns
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.'));
let ok_path = !path.is_empty()
&& path
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' | b'.' | b'/'));
if !ok_ns || !ok_path {
return Err(format!(
"`{whole}`: `{base}` isn't an id (lower case `namespace:path`)"
));
}
if !suffix.is_empty() && !(suffix.ends_with(']') || suffix.ends_with('}')) {
return Err(format!("`{whole}`: unclosed `{}`", &suffix[..1]));
}
Ok(format!("{ns}:{path}{suffix}"))
}
pub fn parse_color(s: &str) -> Option<u32> {
let hex = s.strip_prefix('#')?;
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
match hex.len() {
6 => u32::from_str_radix(hex, 16).ok().map(|c| 0xFF00_0000 | c),
8 => u32::from_str_radix(hex, 16).ok(),
_ => None,
}
}
pub fn format_color(c: u32) -> String {
if c >> 24 == 0xFF {
format!("#{:06X}", c & 0xFF_FFFF)
} else {
format!("#{c:08X}")
}
}
fn is_ident(s: &str) -> bool {
let mut chars = s.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn is_kind(s: &str) -> bool {
let mut b = s.bytes();
b.next().is_some_and(|c| c.is_ascii_lowercase())
&& b.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_every_form() {
let r = |k: &str, id: &str| Ref {
kind: k.into(),
id: id.into(),
};
assert_eq!(classify("plain").unwrap(), Value::Str("plain".into()));
assert_eq!(classify("@@me").unwrap(), Value::Str("@me".into()));
assert_eq!(
classify("@item:minecraft:stick").unwrap(),
Value::Ref(r("item", "minecraft:stick"))
);
assert_eq!(
classify("@item:stick").unwrap(),
Value::Ref(r("item", "minecraft:stick"))
);
assert_eq!(
classify("@block:furnace[facing=north]").unwrap(),
Value::Ref(r("block", "minecraft:furnace[facing=north]"))
);
assert_eq!(
classify("@tag:item:minecraft:planks").unwrap(),
Value::Tag(r("item", "minecraft:planks"))
);
assert_eq!(
classify("@pattern:hexmod:get_caster").unwrap(),
Value::Ref(r("pattern", "hexmod:get_caster"))
);
}
#[test]
fn resources_and_images() {
assert_eq!(
classify("@Strings.screen.title").unwrap(),
Value::Res(Res {
set: "Strings".into(),
key: "screen.title".into()
})
);
assert_eq!(
classify("@image:sprite:button").unwrap(),
Value::Image("sprite:button".into())
);
}
#[test]
fn rejects_typos() {
for bad in [
"@",
"@stick",
"@Item:stick",
"@item:Stick",
"@item:",
"@tag:item",
"@strings.title",
"@Strings.",
"@block:furnace[facing=north",
"@image:",
] {
assert!(classify(bad).is_err(), "{bad} should fail");
}
}
#[test]
fn round_trips() {
for s in [
"@item:minecraft:stick",
"@tag:block:minecraft:logs",
"@Strings.a.b",
"@image:sprite:x",
"@@at",
"plain",
] {
assert_eq!(typed_string(&classify(s).unwrap()).unwrap(), s);
}
}
#[test]
fn colors() {
assert_eq!(parse_color("#8d6acc"), Some(0xFF8D6ACC));
assert_eq!(parse_color("#808d6acc"), Some(0x808D6ACC));
assert_eq!(parse_color("#fff"), None);
assert_eq!(format_color(0xFF8D6ACC), "#8D6ACC");
assert_eq!(format_color(0x808D6ACC), "#808D6ACC");
}
}