1use 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
31pub fn bare(id: &str) -> &str {
33 id.split(yog_ids::sep::STATE_OPEN).next().unwrap_or(id)
34}
35
36pub 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
49pub fn link_target(l: &str) -> &str {
51 l.strip_prefix("@entry:").unwrap_or(l)
52}
53
54pub struct BookText;
57
58impl With<String> for BookText {
59 fn shape() -> Shape {
60 Text::shape()
61 }
62
63 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
79fn 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
104fn 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
115const ICON_KINDS: &[&str] = &[kinds::Item::NAME, kinds::Block::NAME, kinds::Image::NAME];
117
118pub 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 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}