Skip to main content

yog_book/
refs.rs

1//! References in a book: what a `.ybook` names — items, blocks, entities,
2//! recipes, its own entries and categories — it names through a typed
3//! reference, `@item:minecraft:stick`, never a bare game id. Tools check
4//! every reference (`yog build`: does it exist on every Minecraft version
5//! the mod targets?) and turn it into the id the game uses.
6//!
7//! | Field | Takes |
8//! |---|---|
9//! | `entries[].category` | `@category:` |
10//! | `icon` (categories, entries) | `@item:`, `@block:`, or an image (`ns:textures/x.png`) |
11//! | `spotlight.item` | `@item:`, `@block:` |
12//! | `recipe.recipe` | `@recipe:` |
13//! | `entity.entity` | `@entity:` |
14//! | `multiblock.mapping` values | `@block:`, with a state: `@block:minecraft:furnace[facing=north]` |
15//! | `relations.entries` | `@entry:` |
16//!
17//! Texts (names, titles, page texts, …) may be whole references to texts
18//! (`@Book.title`) or names (`@item:mymod:ruby` shows the item's name), and
19//! link entries with `{link=@entry:mymod:basics|text}`.
20
21use serde_json::Value;
22
23/// What a reference names.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum RefKind {
26    Item,
27    Block,
28    Entity,
29    Recipe,
30    Entry,
31    Category,
32}
33
34impl RefKind {
35    pub const ALL: [RefKind; 6] =
36        [RefKind::Item, RefKind::Block, RefKind::Entity, RefKind::Recipe, RefKind::Entry, RefKind::Category];
37
38    /// Its prefix: `item` for `@item:`.
39    pub fn word(self) -> &'static str {
40        match self {
41            RefKind::Item => "item",
42            RefKind::Block => "block",
43            RefKind::Entity => "entity",
44            RefKind::Recipe => "recipe",
45            RefKind::Entry => "entry",
46            RefKind::Category => "category",
47        }
48    }
49}
50
51/// `@item:minecraft:stick` → (Item, `minecraft:stick`). A block's state
52/// (`[facing=north]`) stays on the id.
53pub fn parse(s: &str) -> Option<(RefKind, &str)> {
54    let rest = s.trim().strip_prefix('@')?;
55    let (word, id) = rest.split_once(':')?;
56    let kind = RefKind::ALL.into_iter().find(|k| k.word() == word)?;
57    (!id.is_empty()).then_some((kind, id))
58}
59
60/// The id without a block state: `minecraft:furnace[facing=north]` → `minecraft:furnace`.
61pub fn bare(id: &str) -> &str {
62    id.split('[').next().unwrap_or(id)
63}
64
65/// What one field of a book holds.
66#[derive(Clone, Copy)]
67enum Field {
68    /// Shown text.
69    Text,
70    /// A reference of one of these kinds.
71    Ref(&'static [RefKind]),
72    /// `@item:`/`@block:`, or an image source as it is.
73    Icon,
74    /// Anything else (numbers, colors, ids it defines, image sources).
75    Plain,
76}
77
78const ITEMS: &[RefKind] = &[RefKind::Item, RefKind::Block];
79
80/// What the field `key` of a page of type `page` (none: not a page) holds.
81fn field(page: Option<&str>, key: &str) -> Field {
82    match (page, key) {
83        (_, "name" | "title" | "text" | "description" | "landing_text" | "subtitle" | "input" | "output") => Field::Text,
84        (None, "icon") => Field::Icon,
85        (None, "category") => Field::Ref(&[RefKind::Category]),
86        (Some("spotlight"), "item") => Field::Ref(ITEMS),
87        (Some("recipe"), "recipe") => Field::Ref(&[RefKind::Recipe]),
88        (Some("entity"), "entity") => Field::Ref(&[RefKind::Entity]),
89        (Some("multiblock"), "mapping") => Field::Ref(&[RefKind::Block]),
90        (Some("relations"), "entries") => Field::Ref(&[RefKind::Entry]),
91        _ => Field::Plain,
92    }
93}
94
95/// What a tool does with a book's references and texts.
96pub trait Resolver {
97    /// A reference of `kind` to `id` at `path` (`entries[2].pages[0].item`):
98    /// the id the game uses, or why it's wrong.
99    fn reference(&mut self, kind: RefKind, id: &str, path: &str) -> Result<String, String>;
100    /// A text: what to put instead (a translation key for `@Book.title`, a
101    /// name for `@item:…`), `None` to keep it. Its links are checked
102    /// through [`Resolver::reference`] already.
103    fn text(&mut self, text: &str, path: &str) -> Option<String>;
104    /// The text a whole-text reference stands for (`@Book.title` → its
105    /// words), to check the links in it; `None` if unknown.
106    fn text_of(&mut self, _text: &str) -> Option<String> {
107        None
108    }
109}
110
111/// Every `{link=@entry:…|` in `text`: (the reference as written).
112pub fn links(text: &str) -> Vec<&str> {
113    let mut out = Vec::new();
114    let mut rest = text;
115    while let Some(i) = rest.find("{link=") {
116        rest = &rest[i + 6..];
117        let end = rest.find(['|', ' ', '}']).unwrap_or(rest.len());
118        out.push(&rest[..end]);
119        rest = &rest[end..];
120    }
121    out
122}
123
124/// Resolves every reference and text of `book` (a `.ybook` as JSON) in
125/// place; returns what's wrong, each with its path.
126pub fn resolve(book: &mut Value, r: &mut dyn Resolver) -> Vec<String> {
127    let mut errors = Vec::new();
128    walk(book, None, "", r, &mut errors);
129    errors
130}
131
132fn walk(v: &mut Value, page: Option<&str>, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
133    let Value::Object(map) = v else { return };
134    let page_type = map.get("type").and_then(Value::as_str).map(str::to_string);
135    let page = page.or(page_type.as_deref()).map(str::to_string);
136    for (key, val) in map.iter_mut() {
137        let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
138        match key.as_str() {
139            "categories" | "entries" if page.is_none() => {
140                if let Value::Array(items) = val {
141                    for (i, item) in items.iter_mut().enumerate() {
142                        walk(item, None, &format!("{here}[{i}]"), r, errors);
143                    }
144                }
145                continue;
146            }
147            "pages" => {
148                if let Value::Array(items) = val {
149                    for (i, item) in items.iter_mut().enumerate() {
150                        let t = item.get("type").and_then(Value::as_str).unwrap_or_default().to_string();
151                        walk(item, Some(&t), &format!("{here}[{i}]"), r, errors);
152                    }
153                }
154                continue;
155            }
156            _ => {}
157        }
158        match field(page.as_deref(), key) {
159            Field::Text => text(val, &here, r, errors),
160            Field::Ref(kinds) => each_string(val, &here, &mut |s, at| reference(s, kinds, at, r, errors)),
161            Field::Icon => {
162                if let Value::String(s) = val
163                    && s.starts_with('@')
164                {
165                    *s = reference(s, ITEMS, &here, r, errors);
166                }
167            }
168            Field::Plain => {}
169        }
170    }
171}
172
173/// `f` on every string of `v`: itself, an array's, an object's values.
174fn each_string(v: &mut Value, path: &str, f: &mut dyn FnMut(&str, &str) -> String) {
175    match v {
176        Value::String(s) => *s = f(s, path),
177        Value::Array(items) => {
178            for (i, item) in items.iter_mut().enumerate() {
179                each_string(item, &format!("{path}[{i}]"), f);
180            }
181        }
182        Value::Object(map) => {
183            for (k, item) in map.iter_mut() {
184                each_string(item, &format!("{path}.{k}"), f);
185            }
186        }
187        _ => {}
188    }
189}
190
191fn reference(s: &str, kinds: &[RefKind], path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) -> String {
192    let wanted = || kinds.iter().map(|k| format!("@{}:", k.word())).collect::<Vec<_>>().join(" or ");
193    match parse(s) {
194        Some((kind, id)) if kinds.contains(&kind) => match r.reference(kind, id, path) {
195            Ok(resolved) => resolved,
196            Err(e) => {
197                errors.push(format!("{path}: {e}"));
198                s.to_string()
199            }
200        },
201        Some((kind, _)) => {
202            errors.push(format!("{path}: `{s}` is a reference to {} — this takes {}", kind.word(), wanted()));
203            s.to_string()
204        }
205        None => {
206            errors.push(format!("{path}: `{s}`: name it by a reference — `@{}:{}`", kinds[0].word(), s.trim_start_matches('@')));
207            s.to_string()
208        }
209    }
210}
211
212fn text(v: &mut Value, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
213    let Value::String(s) = v else { return };
214    let words = if s.starts_with('@') && !s.starts_with("@@") { r.text_of(s) } else { None };
215    for l in links(words.as_deref().unwrap_or(s)) {
216        match parse(l) {
217            Some((RefKind::Entry, id)) => {
218                if let Err(e) = r.reference(RefKind::Entry, id, path) {
219                    errors.push(format!("{path}: link: {e}"));
220                }
221            }
222            // A web address links outside the book.
223            _ if l.starts_with("http://") || l.starts_with("https://") => {}
224            _ => errors.push(format!("{path}: link `{l}`: write {{link=@entry:ns:entry|…}}")),
225        }
226    }
227    if let Some(t) = r.text(s, path) {
228        *s = t;
229    }
230}
231
232/// A link's target as the reader follows it: `@entry:mymod:basics` → `mymod:basics`.
233pub fn link_target(l: &str) -> &str {
234    match parse(l) {
235        Some((RefKind::Entry, id)) => id,
236        _ => l,
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    struct Check(Vec<(RefKind, String)>);
245
246    impl Resolver for Check {
247        fn reference(&mut self, kind: RefKind, id: &str, _path: &str) -> Result<String, String> {
248            self.0.push((kind, id.to_string()));
249            if id == "minecraft:nope" { Err("no item minecraft:nope".into()) } else { Ok(id.to_string()) }
250        }
251        fn text(&mut self, text: &str, _path: &str) -> Option<String> {
252            text.strip_prefix("@Book.").map(|k| format!("mymod.{k}"))
253        }
254    }
255
256    #[test]
257    fn references_resolve_and_bare_ids_are_errors() {
258        let mut book = serde_json::json!({
259            "id": "mymod:guide", "name": "@Book.title",
260            "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
261            "entries": [{
262                "id": "mymod:ruby", "name": "Ruby", "category": "@category:mymod:basics", "icon": "minecraft:textures/item/book.png",
263                "pages": [
264                    { "type": "spotlight", "item": "@block:minecraft:stone", "text": "See {link=@entry:mymod:ruby|this}" },
265                    { "type": "recipe", "recipe": "minecraft:stick" },
266                    { "type": "multiblock", "pattern": [["A"]], "mapping": { "A": "@block:minecraft:furnace[facing=north]" } },
267                    { "type": "entity", "entity": "@item:minecraft:nope" },
268                    { "type": "text", "text": "{link=items/staff|old}" }
269                ]
270            }]
271        });
272        let mut c = Check(Vec::new());
273        let errors = resolve(&mut book, &mut c);
274        assert_eq!(book["name"], "mymod.title");
275        assert_eq!(book["categories"][0]["icon"], "minecraft:book");
276        assert_eq!(book["entries"][0]["category"], "mymod:basics");
277        assert_eq!(book["entries"][0]["icon"], "minecraft:textures/item/book.png", "an image stays");
278        assert_eq!(book["entries"][0]["pages"][0]["item"], "minecraft:stone");
279        assert_eq!(book["entries"][0]["pages"][2]["mapping"]["A"], "minecraft:furnace[facing=north]");
280        assert!(c.0.contains(&(RefKind::Entry, "mymod:ruby".into())), "links are checked");
281        assert_eq!(errors.len(), 3, "{errors:#?}");
282        assert!(errors[0].contains("entries[0].pages[1].recipe") && errors[0].contains("`@recipe:minecraft:stick`"));
283        assert!(errors[1].contains("is a reference to item"));
284        assert!(errors[2].contains("link `items/staff`"));
285    }
286}