use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RefKind {
Item,
Block,
Entity,
Recipe,
Entry,
Category,
}
impl RefKind {
pub const ALL: [RefKind; 6] =
[RefKind::Item, RefKind::Block, RefKind::Entity, RefKind::Recipe, RefKind::Entry, RefKind::Category];
pub fn word(self) -> &'static str {
match self {
RefKind::Item => "item",
RefKind::Block => "block",
RefKind::Entity => "entity",
RefKind::Recipe => "recipe",
RefKind::Entry => "entry",
RefKind::Category => "category",
}
}
}
pub fn parse(s: &str) -> Option<(RefKind, &str)> {
let rest = s.trim().strip_prefix('@')?;
let (word, id) = rest.split_once(':')?;
let kind = RefKind::ALL.into_iter().find(|k| k.word() == word)?;
(!id.is_empty()).then_some((kind, id))
}
pub fn bare(id: &str) -> &str {
id.split('[').next().unwrap_or(id)
}
#[derive(Clone, Copy)]
enum Field {
Text,
Ref(&'static [RefKind]),
Icon,
Plain,
}
const ITEMS: &[RefKind] = &[RefKind::Item, RefKind::Block];
fn field(page: Option<&str>, key: &str) -> Field {
match (page, key) {
(_, "name" | "title" | "text" | "description" | "landing_text" | "subtitle" | "input" | "output") => Field::Text,
(None, "icon") => Field::Icon,
(None, "category") => Field::Ref(&[RefKind::Category]),
(Some("spotlight"), "item") => Field::Ref(ITEMS),
(Some("recipe"), "recipe") => Field::Ref(&[RefKind::Recipe]),
(Some("entity"), "entity") => Field::Ref(&[RefKind::Entity]),
(Some("multiblock"), "mapping") => Field::Ref(&[RefKind::Block]),
(Some("relations"), "entries") => Field::Ref(&[RefKind::Entry]),
_ => Field::Plain,
}
}
pub trait Resolver {
fn reference(&mut self, kind: RefKind, id: &str, path: &str) -> Result<String, String>;
fn text(&mut self, text: &str, path: &str) -> Option<String>;
fn text_of(&mut self, _text: &str) -> Option<String> {
None
}
}
pub fn links(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut rest = text;
while let Some(i) = rest.find("{link=") {
rest = &rest[i + 6..];
let end = rest.find(['|', ' ', '}']).unwrap_or(rest.len());
out.push(&rest[..end]);
rest = &rest[end..];
}
out
}
pub fn resolve(book: &mut Value, r: &mut dyn Resolver) -> Vec<String> {
let mut errors = Vec::new();
walk(book, None, "", r, &mut errors);
errors
}
fn walk(v: &mut Value, page: Option<&str>, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
let Value::Object(map) = v else { return };
let page_type = map.get("type").and_then(Value::as_str).map(str::to_string);
let page = page.or(page_type.as_deref()).map(str::to_string);
for (key, val) in map.iter_mut() {
let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
match key.as_str() {
"categories" | "entries" if page.is_none() => {
if let Value::Array(items) = val {
for (i, item) in items.iter_mut().enumerate() {
walk(item, None, &format!("{here}[{i}]"), r, errors);
}
}
continue;
}
"pages" => {
if let Value::Array(items) = val {
for (i, item) in items.iter_mut().enumerate() {
let t = item.get("type").and_then(Value::as_str).unwrap_or_default().to_string();
walk(item, Some(&t), &format!("{here}[{i}]"), r, errors);
}
}
continue;
}
_ => {}
}
match field(page.as_deref(), key) {
Field::Text => text(val, &here, r, errors),
Field::Ref(kinds) => each_string(val, &here, &mut |s, at| reference(s, kinds, at, r, errors)),
Field::Icon => {
if let Value::String(s) = val
&& s.starts_with('@')
{
*s = reference(s, ITEMS, &here, r, errors);
}
}
Field::Plain => {}
}
}
}
fn each_string(v: &mut Value, path: &str, f: &mut dyn FnMut(&str, &str) -> String) {
match v {
Value::String(s) => *s = f(s, path),
Value::Array(items) => {
for (i, item) in items.iter_mut().enumerate() {
each_string(item, &format!("{path}[{i}]"), f);
}
}
Value::Object(map) => {
for (k, item) in map.iter_mut() {
each_string(item, &format!("{path}.{k}"), f);
}
}
_ => {}
}
}
fn reference(s: &str, kinds: &[RefKind], path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) -> String {
let wanted = || kinds.iter().map(|k| format!("@{}:", k.word())).collect::<Vec<_>>().join(" or ");
match parse(s) {
Some((kind, id)) if kinds.contains(&kind) => match r.reference(kind, id, path) {
Ok(resolved) => resolved,
Err(e) => {
errors.push(format!("{path}: {e}"));
s.to_string()
}
},
Some((kind, _)) => {
errors.push(format!("{path}: `{s}` is a reference to {} — this takes {}", kind.word(), wanted()));
s.to_string()
}
None => {
errors.push(format!("{path}: `{s}`: name it by a reference — `@{}:{}`", kinds[0].word(), s.trim_start_matches('@')));
s.to_string()
}
}
}
fn text(v: &mut Value, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
let Value::String(s) = v else { return };
let words = if s.starts_with('@') && !s.starts_with("@@") { r.text_of(s) } else { None };
for l in links(words.as_deref().unwrap_or(s)) {
match parse(l) {
Some((RefKind::Entry, id)) => {
if let Err(e) = r.reference(RefKind::Entry, id, path) {
errors.push(format!("{path}: link: {e}"));
}
}
_ if l.starts_with("http://") || l.starts_with("https://") => {}
_ => errors.push(format!("{path}: link `{l}`: write {{link=@entry:ns:entry|…}}")),
}
}
if let Some(t) = r.text(s, path) {
*s = t;
}
}
pub fn link_target(l: &str) -> &str {
match parse(l) {
Some((RefKind::Entry, id)) => id,
_ => l,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Check(Vec<(RefKind, String)>);
impl Resolver for Check {
fn reference(&mut self, kind: RefKind, id: &str, _path: &str) -> Result<String, String> {
self.0.push((kind, id.to_string()));
if id == "minecraft:nope" { Err("no item minecraft:nope".into()) } else { Ok(id.to_string()) }
}
fn text(&mut self, text: &str, _path: &str) -> Option<String> {
text.strip_prefix("@Book.").map(|k| format!("mymod.{k}"))
}
}
#[test]
fn references_resolve_and_bare_ids_are_errors() {
let mut book = serde_json::json!({
"id": "mymod:guide", "name": "@Book.title",
"categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
"entries": [{
"id": "mymod:ruby", "name": "Ruby", "category": "@category:mymod:basics", "icon": "minecraft:textures/item/book.png",
"pages": [
{ "type": "spotlight", "item": "@block:minecraft:stone", "text": "See {link=@entry:mymod:ruby|this}" },
{ "type": "recipe", "recipe": "minecraft:stick" },
{ "type": "multiblock", "pattern": [["A"]], "mapping": { "A": "@block:minecraft:furnace[facing=north]" } },
{ "type": "entity", "entity": "@item:minecraft:nope" },
{ "type": "text", "text": "{link=items/staff|old}" }
]
}]
});
let mut c = Check(Vec::new());
let errors = resolve(&mut book, &mut c);
assert_eq!(book["name"], "mymod.title");
assert_eq!(book["categories"][0]["icon"], "minecraft:book");
assert_eq!(book["entries"][0]["category"], "mymod:basics");
assert_eq!(book["entries"][0]["icon"], "minecraft:textures/item/book.png", "an image stays");
assert_eq!(book["entries"][0]["pages"][0]["item"], "minecraft:stone");
assert_eq!(book["entries"][0]["pages"][2]["mapping"]["A"], "minecraft:furnace[facing=north]");
assert!(c.0.contains(&(RefKind::Entry, "mymod:ruby".into())), "links are checked");
assert_eq!(errors.len(), 3, "{errors:#?}");
assert!(errors[0].contains("entries[0].pages[1].recipe") && errors[0].contains("`@recipe:minecraft:stick`"));
assert!(errors[1].contains("is a reference to item"));
assert!(errors[2].contains("link `items/staff`"));
}
}