1use 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
30pub fn bare(id: &str) -> &str {
32 id.split(yog_ids::sep::STATE_OPEN).next().unwrap_or(id)
33}
34
35pub 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
48pub fn link_target(l: &str) -> &str {
50 l.strip_prefix("@entry:").unwrap_or(l)
51}
52
53pub 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
98fn 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
109pub 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 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}