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