1use serde_json::Value;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum RefKind {
26 Item,
27 Block,
28 Entity,
29 Recipe,
30 Entry,
31 Category,
32}
33
34impl RefKind {
35 pub const ALL: [RefKind; 6] =
36 [RefKind::Item, RefKind::Block, RefKind::Entity, RefKind::Recipe, RefKind::Entry, RefKind::Category];
37
38 pub fn word(self) -> &'static str {
40 match self {
41 RefKind::Item => "item",
42 RefKind::Block => "block",
43 RefKind::Entity => "entity",
44 RefKind::Recipe => "recipe",
45 RefKind::Entry => "entry",
46 RefKind::Category => "category",
47 }
48 }
49}
50
51pub fn parse(s: &str) -> Option<(RefKind, &str)> {
54 let rest = s.trim().strip_prefix('@')?;
55 let (word, id) = rest.split_once(':')?;
56 let kind = RefKind::ALL.into_iter().find(|k| k.word() == word)?;
57 (!id.is_empty()).then_some((kind, id))
58}
59
60pub fn bare(id: &str) -> &str {
62 id.split('[').next().unwrap_or(id)
63}
64
65#[derive(Clone, Copy)]
67enum Field {
68 Text,
70 Ref(&'static [RefKind]),
72 Icon,
74 Plain,
76}
77
78const ITEMS: &[RefKind] = &[RefKind::Item, RefKind::Block];
79
80fn field(page: Option<&str>, key: &str) -> Field {
82 match (page, key) {
83 (_, "name" | "title" | "text" | "description" | "landing_text" | "subtitle" | "input" | "output") => Field::Text,
84 (None, "icon") => Field::Icon,
85 (None, "category") => Field::Ref(&[RefKind::Category]),
86 (Some("spotlight"), "item") => Field::Ref(ITEMS),
87 (Some("recipe"), "recipe") => Field::Ref(&[RefKind::Recipe]),
88 (Some("entity"), "entity") => Field::Ref(&[RefKind::Entity]),
89 (Some("multiblock"), "mapping") => Field::Ref(&[RefKind::Block]),
90 (Some("relations"), "entries") => Field::Ref(&[RefKind::Entry]),
91 _ => Field::Plain,
92 }
93}
94
95pub trait Resolver {
97 fn reference(&mut self, kind: RefKind, id: &str, path: &str) -> Result<String, String>;
100 fn text(&mut self, text: &str, path: &str) -> Option<String>;
104 fn text_of(&mut self, _text: &str) -> Option<String> {
107 None
108 }
109}
110
111pub fn links(text: &str) -> Vec<&str> {
113 let mut out = Vec::new();
114 let mut rest = text;
115 while let Some(i) = rest.find("{link=") {
116 rest = &rest[i + 6..];
117 let end = rest.find(['|', ' ', '}']).unwrap_or(rest.len());
118 out.push(&rest[..end]);
119 rest = &rest[end..];
120 }
121 out
122}
123
124pub fn resolve(book: &mut Value, r: &mut dyn Resolver) -> Vec<String> {
127 let mut errors = Vec::new();
128 walk(book, None, "", r, &mut errors);
129 errors
130}
131
132fn walk(v: &mut Value, page: Option<&str>, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
133 let Value::Object(map) = v else { return };
134 let page_type = map.get("type").and_then(Value::as_str).map(str::to_string);
135 let page = page.or(page_type.as_deref()).map(str::to_string);
136 for (key, val) in map.iter_mut() {
137 let here = if path.is_empty() { key.clone() } else { format!("{path}.{key}") };
138 match key.as_str() {
139 "categories" | "entries" if page.is_none() => {
140 if let Value::Array(items) = val {
141 for (i, item) in items.iter_mut().enumerate() {
142 walk(item, None, &format!("{here}[{i}]"), r, errors);
143 }
144 }
145 continue;
146 }
147 "pages" => {
148 if let Value::Array(items) = val {
149 for (i, item) in items.iter_mut().enumerate() {
150 let t = item.get("type").and_then(Value::as_str).unwrap_or_default().to_string();
151 walk(item, Some(&t), &format!("{here}[{i}]"), r, errors);
152 }
153 }
154 continue;
155 }
156 _ => {}
157 }
158 match field(page.as_deref(), key) {
159 Field::Text => text(val, &here, r, errors),
160 Field::Ref(kinds) => each_string(val, &here, &mut |s, at| reference(s, kinds, at, r, errors)),
161 Field::Icon => {
162 if let Value::String(s) = val
163 && s.starts_with('@')
164 {
165 *s = reference(s, ITEMS, &here, r, errors);
166 }
167 }
168 Field::Plain => {}
169 }
170 }
171}
172
173fn each_string(v: &mut Value, path: &str, f: &mut dyn FnMut(&str, &str) -> String) {
175 match v {
176 Value::String(s) => *s = f(s, path),
177 Value::Array(items) => {
178 for (i, item) in items.iter_mut().enumerate() {
179 each_string(item, &format!("{path}[{i}]"), f);
180 }
181 }
182 Value::Object(map) => {
183 for (k, item) in map.iter_mut() {
184 each_string(item, &format!("{path}.{k}"), f);
185 }
186 }
187 _ => {}
188 }
189}
190
191fn reference(s: &str, kinds: &[RefKind], path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) -> String {
192 let wanted = || kinds.iter().map(|k| format!("@{}:", k.word())).collect::<Vec<_>>().join(" or ");
193 match parse(s) {
194 Some((kind, id)) if kinds.contains(&kind) => match r.reference(kind, id, path) {
195 Ok(resolved) => resolved,
196 Err(e) => {
197 errors.push(format!("{path}: {e}"));
198 s.to_string()
199 }
200 },
201 Some((kind, _)) => {
202 errors.push(format!("{path}: `{s}` is a reference to {} — this takes {}", kind.word(), wanted()));
203 s.to_string()
204 }
205 None => {
206 errors.push(format!("{path}: `{s}`: name it by a reference — `@{}:{}`", kinds[0].word(), s.trim_start_matches('@')));
207 s.to_string()
208 }
209 }
210}
211
212fn text(v: &mut Value, path: &str, r: &mut dyn Resolver, errors: &mut Vec<String>) {
213 let Value::String(s) = v else { return };
214 let words = if s.starts_with('@') && !s.starts_with("@@") { r.text_of(s) } else { None };
215 for l in links(words.as_deref().unwrap_or(s)) {
216 match parse(l) {
217 Some((RefKind::Entry, id)) => {
218 if let Err(e) = r.reference(RefKind::Entry, id, path) {
219 errors.push(format!("{path}: link: {e}"));
220 }
221 }
222 _ if l.starts_with("http://") || l.starts_with("https://") => {}
224 _ => errors.push(format!("{path}: link `{l}`: write {{link=@entry:ns:entry|…}}")),
225 }
226 }
227 if let Some(t) = r.text(s, path) {
228 *s = t;
229 }
230}
231
232pub fn link_target(l: &str) -> &str {
234 match parse(l) {
235 Some((RefKind::Entry, id)) => id,
236 _ => l,
237 }
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 struct Check(Vec<(RefKind, String)>);
245
246 impl Resolver for Check {
247 fn reference(&mut self, kind: RefKind, id: &str, _path: &str) -> Result<String, String> {
248 self.0.push((kind, id.to_string()));
249 if id == "minecraft:nope" { Err("no item minecraft:nope".into()) } else { Ok(id.to_string()) }
250 }
251 fn text(&mut self, text: &str, _path: &str) -> Option<String> {
252 text.strip_prefix("@Book.").map(|k| format!("mymod.{k}"))
253 }
254 }
255
256 #[test]
257 fn references_resolve_and_bare_ids_are_errors() {
258 let mut book = serde_json::json!({
259 "id": "mymod:guide", "name": "@Book.title",
260 "categories": [{ "id": "mymod:basics", "name": "Basics", "icon": "@item:minecraft:book" }],
261 "entries": [{
262 "id": "mymod:ruby", "name": "Ruby", "category": "@category:mymod:basics", "icon": "minecraft:textures/item/book.png",
263 "pages": [
264 { "type": "spotlight", "item": "@block:minecraft:stone", "text": "See {link=@entry:mymod:ruby|this}" },
265 { "type": "recipe", "recipe": "minecraft:stick" },
266 { "type": "multiblock", "pattern": [["A"]], "mapping": { "A": "@block:minecraft:furnace[facing=north]" } },
267 { "type": "entity", "entity": "@item:minecraft:nope" },
268 { "type": "text", "text": "{link=items/staff|old}" }
269 ]
270 }]
271 });
272 let mut c = Check(Vec::new());
273 let errors = resolve(&mut book, &mut c);
274 assert_eq!(book["name"], "mymod.title");
275 assert_eq!(book["categories"][0]["icon"], "minecraft:book");
276 assert_eq!(book["entries"][0]["category"], "mymod:basics");
277 assert_eq!(book["entries"][0]["icon"], "minecraft:textures/item/book.png", "an image stays");
278 assert_eq!(book["entries"][0]["pages"][0]["item"], "minecraft:stone");
279 assert_eq!(book["entries"][0]["pages"][2]["mapping"]["A"], "minecraft:furnace[facing=north]");
280 assert!(c.0.contains(&(RefKind::Entry, "mymod:ruby".into())), "links are checked");
281 assert_eq!(errors.len(), 3, "{errors:#?}");
282 assert!(errors[0].contains("entries[0].pages[1].recipe") && errors[0].contains("`@recipe:minecraft:stick`"));
283 assert!(errors[1].contains("is a reference to item"));
284 assert!(errors[2].contains("link `items/staff`"));
285 }
286}