Skip to main content

yog_formats/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. The schema
4//! ([`super::Book`]'s `#[derive(Schema)]`) says which field takes what;
5//! tools check every reference through a [`Resolve`] (`yog build`: does it
6//! exist on every Minecraft version the mod targets?) and get the id the
7//! game uses.
8//!
9//! | Field | Takes |
10//! |---|---|
11//! | `entries[].category` | `@category:` |
12//! | `icon` (categories, entries) | `@item:`, `@block:`, or an image (`ns:textures/x.png`) |
13//! | `spotlight.item` | `@item:`, `@block:` |
14//! | `recipe.recipe` | `@recipe:` |
15//! | `entity.entity` | `@entity:` |
16//! | `multiblock.mapping` values | `@block:`, with a state: `@block:minecraft:furnace[facing=north]` |
17//! | `relations.entries` | `@entry:` |
18//!
19//! Texts (names, titles, page texts, …) may be whole references to texts
20//! (`@Book.title`), names (`@item:mymod:ruby` shows the item's name) or
21//! any translation key (`@lang:gui.done`), and
22//! link entries with `{link=@entry:mymod:basics|text}`.
23
24use yog_data::schema::{Read, Resolve, Text, read_ref};
25use yog_data::with::With;
26use yog_data::{Errors, Ref, Schema, Shape, Value, ValueRef, typed};
27
28use super::Book;
29
30/// The id without a block state: `minecraft:furnace[facing=north]` → `minecraft:furnace`.
31pub fn bare(id: &str) -> &str {
32    id.split(yog_ids::sep::STATE_OPEN).next().unwrap_or(id)
33}
34
35/// Every `{link=…|` in `text` (the target as written).
36pub fn links(text: &str) -> Vec<&str> {
37    let mut out = Vec::new();
38    let mut rest = text;
39    while let Some(i) = rest.find("{link=") {
40        rest = &rest[i + 6..];
41        let end = rest.find(['|', ' ', '}']).unwrap_or(rest.len());
42        out.push(&rest[..end]);
43        rest = &rest[end..];
44    }
45    out
46}
47
48/// A link's target as the reader follows it: `@entry:mymod:basics` → `mymod:basics`.
49pub fn link_target(l: &str) -> &str {
50    l.strip_prefix("@entry:").unwrap_or(l)
51}
52
53/// A shown text: as [`yog_data::with::TextOf`], and every link in it
54/// (`{link=@entry:…|…}`, or a web address) checked.
55pub struct BookText;
56
57impl With<String> for BookText {
58    fn shape() -> Shape {
59        Shape::Text
60    }
61
62    fn read(v: &ValueRef<'_>, cx: &mut yog_data::Reader) -> Option<String> {
63        let t = Text::read(v, cx)?;
64        let words: std::borrow::Cow<str> = match &t {
65            Text::Literal(s) => s.as_str().into(),
66            Text::Res(r) => cx.words(r).unwrap_or_default().into(),
67            Text::NameOf(_) | Text::Lang(_) => "".into(),
68        };
69        let mut ok = true;
70        for l in links(&words) {
71            if l.starts_with("http://") || l.starts_with("https://") {
72                continue;
73            }
74            match typed::classify(l) {
75                Ok(Value::Ref(r)) if r.kind == yog_ids::kind::ENTRY => {
76                    cx.note_ref(r.clone());
77                    if let Err(e) = resolve_link(cx, &r) {
78                        cx.problem(format!("link: {e}"));
79                        ok = false;
80                    }
81                }
82                _ => {
83                    cx.problem(format!("link `{l}`: write {{link=@entry:ns:entry|…}}"));
84                    ok = false;
85                }
86            }
87        }
88        drop(words);
89        let resolved = cx.take_text(t)?;
90        ok.then_some(resolved)
91    }
92
93    fn write(f: &String) -> Value {
94        Value::Text(f.clone())
95    }
96}
97
98/// A link's entry, checked like a reference (the problem is the caller's to word).
99fn resolve_link(cx: &mut yog_data::Reader, r: &Ref) -> Result<(), String> {
100    let before = cx.problems.len();
101    let ok = cx.resolve_ref(r).is_some();
102    if ok {
103        return Ok(());
104    }
105    let p = cx.problems.remove(before);
106    Err(p.message)
107}
108
109/// An icon: `@item:`/`@block:` (resolved to the id) or an image source as it is.
110pub struct Icon;
111
112impl With<String> for Icon {
113    fn shape() -> Shape {
114        Shape::RefOrImage(&[yog_ids::kind::ITEM, yog_ids::kind::BLOCK])
115    }
116
117    fn read(v: &ValueRef<'_>, cx: &mut yog_data::Reader) -> Option<String> {
118        match v {
119            ValueRef::Str(s) if !s.starts_with('@') => Some(s.to_string()),
120            ValueRef::Image(s) => Some(s.to_string()),
121            _ => {
122                let r = read_ref(v, cx, &[yog_ids::kind::ITEM, yog_ids::kind::BLOCK])?;
123                cx.resolve_ref(&r)
124            }
125        }
126    }
127
128    fn write(f: &String) -> Value {
129        Value::Str(f.clone())
130    }
131}
132
133impl Book {
134    /// Reads a `.ybook` (`text`), references and texts going through
135    /// `resolver`: the book as the game shows it, and what it references.
136    pub fn read(text: &str, resolver: &mut dyn Resolve) -> Result<Read<Book>, Errors> {
137        let doc = yog_data::text::parse(text).map_err(Errors::from)?;
138        yog_data::from_doc_with(text, &doc, resolver)
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use crate::book::BookPage;
146    use yog_data::Path;
147
148    struct Check(Vec<String>);
149
150    impl Resolve for Check {
151        fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
152            self.0.push(format!("{}:{}", r.kind, r.id));
153            if r.id == "minecraft:nope" {
154                Err("no item minecraft:nope".into())
155            } else {
156                Ok(r.id.clone())
157            }
158        }
159        fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
160            Ok(match t {
161                Text::Res(r) if r.set == "Book" => format!("mymod.{}", r.key),
162                Text::Literal(s) => s.clone(),
163                other => format!("{other:?}"),
164            })
165        }
166    }
167
168    const BOOK: &str = r#"{
169  id: "mymod:guide", name: "@Book.title",
170  categories: [{ id: "mymod:basics", name: "Basics", icon: "@item:minecraft:book" }],
171  entries: [{
172    id: "mymod:ruby", name: "Ruby", category: "@category:mymod:basics", icon: "minecraft:textures/item/book.png",
173    pages: [
174      { type: "spotlight", item: "@block:minecraft:stone", text: "See {link=@entry:mymod:ruby|this}" },
175      { type: "multiblock", pattern: [["A"]], mapping: { A: "@block:minecraft:furnace[facing=north]" } },
176    ],
177  }],
178}"#;
179
180    #[test]
181    fn references_resolve_to_plain_ids() {
182        let mut c = Check(Vec::new());
183        let book = Book::read(BOOK, &mut c)
184            .unwrap_or_else(|e| panic!("{e}"))
185            .value;
186        assert_eq!(book.name, "mymod.title");
187        assert_eq!(book.categories[0].icon.as_deref(), Some("minecraft:book"));
188        assert_eq!(book.entries[0].category, "mymod:basics");
189        assert_eq!(
190            book.entries[0].icon.as_deref(),
191            Some("minecraft:textures/item/book.png"),
192            "an image stays"
193        );
194        assert!(
195            matches!(&book.entries[0].pages[0], BookPage::Spotlight { item, .. } if item == "minecraft:stone")
196        );
197        assert!(
198            matches!(&book.entries[0].pages[1], BookPage::Multiblock { mapping, .. } if mapping["A"] == "minecraft:furnace[facing=north]")
199        );
200        assert!(
201            c.0.contains(&"entry:mymod:ruby".to_string()),
202            "links are checked"
203        );
204        assert_eq!(book.nameplate_color, "FFDD98", "defaults as the runtime's");
205    }
206
207    #[test]
208    fn bare_ids_wrong_kinds_and_bad_links_are_errors() {
209        let bad = r#"{
210  id: "mymod:guide", name: "Guide",
211  entries: [{ id: "e", name: "E", category: "@category:c", pages: [
212    { type: "recipe", recipe: "minecraft:stick" },
213    { type: "entity", entity: "@item:minecraft:nope" },
214    { type: "text", text: "{link=items/staff|old}" },
215  ]}],
216}"#;
217        let e = Book::read(bad, &mut Check(Vec::new()))
218            .unwrap_err()
219            .to_string();
220        assert_eq!(
221            e.lines().collect::<Vec<_>>(),
222            [
223                "4:31: entries[0].pages[0].recipe: `minecraft:stick`: name it by a reference — `@recipe:minecraft:stick`",
224                "5:31: entries[0].pages[1].entity: `@item:minecraft:nope` is a reference to item — this takes `@entity:`",
225                "6:27: entries[0].pages[2].text: link `items/staff`: write {link=@entry:ns:entry|…}",
226            ]
227        );
228    }
229}