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. The schema
4//! ([`crate::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 crate::Book;
29
30/// The id without a block state: `minecraft:furnace[facing=north]` → `minecraft:furnace`.
31pub fn bare(id: &str) -> &str {
32    id.split('[').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 == "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(&["item", "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, &["item", "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(|e| {
138            Errors(vec![yog_data::schema::Located {
139                at: Some(e.at),
140                path: yog_data::Path::root(),
141                message: e.message,
142            }])
143        })?;
144        yog_data::from_doc_with(text, &doc, resolver)
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::BookPage;
152    use yog_data::Path;
153
154    struct Check(Vec<String>);
155
156    impl Resolve for Check {
157        fn reference(&mut self, r: &Ref, _: &Path) -> Result<String, String> {
158            self.0.push(format!("{}:{}", r.kind, r.id));
159            if r.id == "minecraft:nope" {
160                Err("no item minecraft:nope".into())
161            } else {
162                Ok(r.id.clone())
163            }
164        }
165        fn text(&mut self, t: &Text, _: &Path) -> Result<String, String> {
166            Ok(match t {
167                Text::Res(r) if r.set == "Book" => format!("mymod.{}", r.key),
168                Text::Literal(s) => s.clone(),
169                other => format!("{other:?}"),
170            })
171        }
172    }
173
174    const BOOK: &str = r#"{
175  id: "mymod:guide", name: "@Book.title",
176  categories: [{ id: "mymod:basics", name: "Basics", icon: "@item:minecraft:book" }],
177  entries: [{
178    id: "mymod:ruby", name: "Ruby", category: "@category:mymod:basics", icon: "minecraft:textures/item/book.png",
179    pages: [
180      { type: "spotlight", item: "@block:minecraft:stone", text: "See {link=@entry:mymod:ruby|this}" },
181      { type: "multiblock", pattern: [["A"]], mapping: { A: "@block:minecraft:furnace[facing=north]" } },
182    ],
183  }],
184}"#;
185
186    #[test]
187    fn references_resolve_to_plain_ids() {
188        let mut c = Check(Vec::new());
189        let book = Book::read(BOOK, &mut c)
190            .unwrap_or_else(|e| panic!("{e}"))
191            .value;
192        assert_eq!(book.name, "mymod.title");
193        assert_eq!(book.categories[0].icon.as_deref(), Some("minecraft:book"));
194        assert_eq!(book.entries[0].category, "mymod:basics");
195        assert_eq!(
196            book.entries[0].icon.as_deref(),
197            Some("minecraft:textures/item/book.png"),
198            "an image stays"
199        );
200        assert!(
201            matches!(&book.entries[0].pages[0], BookPage::Spotlight { item, .. } if item == "minecraft:stone")
202        );
203        assert!(
204            matches!(&book.entries[0].pages[1], BookPage::Multiblock { mapping, .. } if mapping["A"] == "minecraft:furnace[facing=north]")
205        );
206        assert!(
207            c.0.contains(&"entry:mymod:ruby".to_string()),
208            "links are checked"
209        );
210        assert_eq!(book.nameplate_color, "FFDD98", "defaults as the runtime's");
211    }
212
213    #[test]
214    fn bare_ids_wrong_kinds_and_bad_links_are_errors() {
215        let bad = r#"{
216  id: "mymod:guide", name: "Guide",
217  entries: [{ id: "e", name: "E", category: "@category:c", pages: [
218    { type: "recipe", recipe: "minecraft:stick" },
219    { type: "entity", entity: "@item:minecraft:nope" },
220    { type: "text", text: "{link=items/staff|old}" },
221  ]}],
222}"#;
223        let e = Book::read(bad, &mut Check(Vec::new()))
224            .unwrap_err()
225            .to_string();
226        assert_eq!(
227            e.lines().collect::<Vec<_>>(),
228            [
229                "4:31: entries[0].pages[0].recipe: `minecraft:stick`: name it by a reference — `@recipe:minecraft:stick`",
230                "5:31: entries[0].pages[1].entity: `@item:minecraft:nope` is a reference to item — this takes `@entity:`",
231                "6:27: entries[0].pages[2].text: link `items/staff`: write {link=@entry:ns:entry|…}",
232            ]
233        );
234    }
235}