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 crate::Book;
29
30pub fn bare(id: &str) -> &str {
32 id.split('[').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 == "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(&["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 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}