Skip to main content

yog_cli/
serve.rs

1//! `yog serve` — the CLI as a service for tools (Yog-IDLE): one JSON
2//! request per line on stdin, one reply per line on stdout. Every format is
3//! read, written and converted here, so a tool needn't be rebuilt when a
4//! format changes — only this binary.
5//!
6//! ```text
7//! → {"id": 1, "method": "yres.parse", "params": {"text": "{…}"}}
8//! ← {"id": 1, "result": {"entries": […]}}
9//! ← {"id": 2, "error": "…"}
10//! ```
11//!
12//! Methods: see [`dispatch`] and the designers' (`design::service`).
13
14use std::io::{BufRead, Write};
15use std::path::{Path, PathBuf};
16
17use base64::Engine as _;
18use serde_json::{Map, Value, json};
19use yog_formats::yres::{Entry, Kind};
20
21/// Serves requests until stdin ends.
22pub fn run() -> Result<(), String> {
23    let stdin = std::io::stdin();
24    let mut out = std::io::stdout().lock();
25    for line in stdin.lock().lines() {
26        let line = line.map_err(|e| e.to_string())?;
27        if line.trim().is_empty() {
28            continue;
29        }
30        let reply = match serde_json::from_str::<Value>(&line) {
31            Ok(req) => {
32                let id = req.get("id").cloned().unwrap_or(Value::Null);
33                let method = req.get("method").and_then(Value::as_str).unwrap_or_default();
34                let params = req.get("params").cloned().unwrap_or(Value::Null);
35                match dispatch(method, &params) {
36                    Ok(result) => json!({ "id": id, "result": result }),
37                    Err(error) => json!({ "id": id, "error": error }),
38                }
39            }
40            Err(e) => json!({ "id": null, "error": format!("not a request: {e}") }),
41        };
42        writeln!(out, "{reply}").map_err(|e| e.to_string())?;
43        out.flush().map_err(|e| e.to_string())?;
44    }
45    Ok(())
46}
47
48/// A request's string parameter.
49fn str_param<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
50    params.get(name).and_then(Value::as_str).ok_or_else(|| json!(format!("missing string `{name}`")))
51}
52
53/// A request's path parameter.
54fn path_param(params: &Value, name: &str) -> Result<PathBuf, Value> {
55    str_param(params, name).map(PathBuf::from)
56}
57
58/// A request's value parameter, given as JSON text (so its maps keep their order).
59fn data_param(params: &Value, name: &str) -> Result<yog_data::Value, Value> {
60    yog_data::text::parse_value(str_param(params, name)?).map_err(|e| json!(format!("`{name}`: {e}")))
61}
62
63/// Bytes as a reply carries them.
64fn base64(bytes: &[u8]) -> String {
65    base64::engine::general_purpose::STANDARD.encode(bytes)
66}
67
68/// What a method does with its params; an error is a string, or
69/// `{message, line}` for a file that doesn't parse.
70pub fn dispatch(method: &str, params: &Value) -> Result<Value, Value> {
71    match method {
72        // A file taken into a `.yres` entry: its value (`@bytes:…`) and the type its bytes are.
73        "media.import" => {
74            let file = path_param(params, "file")?;
75            let bytes = std::fs::read(&file).map_err(|e| json!(format!("{}: {e}", file.display())))?;
76            let kind = yog_formats::yres::kind_of(&yog_formats::yres::Value::Bytes(bytes.clone()));
77            Ok(json!({ "value": yog_data::typed::typed_string(&yog_data::Value::Bytes(bytes)), "type": kind.name() }))
78        }
79        // The service's version: a tool restarts it when the binary changes.
80        "version" => Ok(json!(env!("CARGO_PKG_VERSION"))),
81        // `.yres` text → its entries; media values as their text form (`@bytes:…`). With the
82        // file's `set` and the `mod` id, a text's translation key too.
83        "yres.parse" => yres_parse(str_param(params, "text")?, params.get("set").and_then(Value::as_str).zip(params.get("mod").and_then(Value::as_str))),
84        // Entries (as `yres.parse` gives them) → `.yres` text.
85        "yres.write" => yres_write(params.get("entries").ok_or_else(|| json!("missing `entries`"))?),
86        // Yog Data text → its value as JSON text (maps in file order, typed values as written).
87        "data.parse" => {
88            let value = yog_data::text::parse_value(str_param(params, "text")?)
89                .map_err(|e| json!({ "message": e.to_string(), "line": e.at.line, "col": e.at.col }))?;
90            Ok(json!(yog_data::patch::to_json(&value)))
91        }
92        // `text` changed to hold `next` (JSON text), comments and layout of the rest kept.
93        "data.patch" => Ok(json!(yog_data::patch::patch(str_param(params, "text")?, &data_param(params, "next")?))),
94        // A value (JSON text) as Yog's editors write it, at indent `level`.
95        "data.format" => {
96            let level = params.get("level").and_then(Value::as_u64).unwrap_or(0) as usize;
97            Ok(json!(yog_data::patch::format(&data_param(params, "value")?, level)))
98        }
99        // Strings as typed values (`@item:x`, `@tag:item:x`, `@Set.key`, `@image:…`, `@lang:…`,
100        // `@@` escaped, else plain text): each `{t, …}` or `{error}`.
101        "typed.classify" => {
102            let values = params.get("values").and_then(Value::as_array).ok_or_else(|| json!("missing list `values`"))?;
103            Ok(Value::Array(values.iter().map(|v| typed_json(v.as_str().unwrap_or_default())).collect()))
104        }
105        // A format's shape (`yog_data::Shape::describe`): `yblock`, `.ytexture`, `yog.toml`…
106        "format.shape" => {
107            let name = str_param(params, "format")?;
108            let shape = yog_formats::shape_of(name).ok_or_else(|| json!(format!("no format `{name}`")))?;
109            Ok(yog_data::json::to_json(&shape.describe()))
110        }
111        // The id of the mod a file is in.
112        "mod.id" => Ok(json!(crate::media::mod_id(&mod_root(&path_param(params, "path")?)?))),
113        // The file the build makes at `assets/<ns>/<path>` (base64), or null.
114        "media.built_asset" => {
115            let root = mod_root(&path_param(params, "path")?)?;
116            Ok(json!(crate::media::built_asset(&root, str_param(params, "ns")?, str_param(params, "asset")?).map(|b| base64(&b))))
117        }
118        // The mod's textures: id, `.ytexture`, picture, atlas.
119        "media.textures" => {
120            let root = mod_root(&path_param(params, "path")?)?;
121            let list: Vec<Value> = crate::media::textures(&root)
122                .into_iter()
123                .map(|t| json!({ "id": t.id, "file": t.file, "image": format!("{}{}.{}", yog_ids::sep::TYPED, t.texture.image.set, t.texture.image.key), "atlas": t.texture.atlas }))
124                .collect();
125            Ok(Value::Array(list))
126        }
127        // Whether texture `id` is the mod's own (it can be painted in place, or added).
128        "media.is_own_texture" => {
129            let root = mod_root(&path_param(params, "path")?)?;
130            Ok(json!(crate::media::is_own_texture(&root, str_param(params, "id")?)))
131        }
132        // The mod's pictures: set, name, path, file (no bytes).
133        "media.pictures" => {
134            let root = mod_root(&path_param(params, "path")?)?;
135            let list: Vec<Value> = crate::media::pictures(&root)
136                .into_iter()
137                .map(|p| json!({ "set": p.set, "name": p.name, "path": p.path(), "file": p.file }))
138                .collect();
139            Ok(Value::Array(list))
140        }
141        // Paints texture `id`: `png` (base64) as its picture; the file written.
142        "media.save_texture_picture" => {
143            let root = mod_root(&path_param(params, "path")?)?;
144            let png = base64::engine::general_purpose::STANDARD.decode(str_param(params, "png")?).map_err(|e| json!(e.to_string()))?;
145            crate::media::save_texture_picture(&root, str_param(params, "id")?, png).map(|f| json!(f)).map_err(Value::from)
146        }
147        // Sets texture `id`'s animation (the game's `.mcmeta` JSON, or null).
148        "media.save_texture_animation" => {
149            let root = mod_root(&path_param(params, "path")?)?;
150            let animation = params.get("mcmeta").filter(|m| !m.is_null()).and_then(yog_formats::texture::Animation::from_mcmeta);
151            crate::media::save_texture_animation(&root, str_param(params, "id")?, animation).map(|f| json!(f)).map_err(Value::from)
152        }
153        // The designers: drawing, pickers' choices, edits of a mod's files.
154        other => crate::design::service::dispatch(other, params).unwrap_or_else(|| Err(json!(format!("no method `{other}`")))),
155    }
156}
157
158/// A string as a typed value, the way a tool takes it.
159fn typed_json(s: &str) -> Value {
160    match yog_data::typed::classify(s) {
161        Ok(yog_data::Value::Ref(r)) => json!({ "t": "ref", "kind": r.kind, "id": r.id }),
162        Ok(yog_data::Value::Tag(r)) => json!({ "t": "tag", "kind": r.kind, "id": r.id }),
163        Ok(yog_data::Value::Res(r)) => json!({ "t": "res", "set": r.set, "key": r.key }),
164        Ok(yog_data::Value::Image(source)) => json!({ "t": "image", "source": source }),
165        Ok(yog_data::Value::Lang(key)) => json!({ "t": "lang", "key": key }),
166        Ok(yog_data::Value::Bytes(_)) => json!({ "t": "bytes" }),
167        Ok(yog_data::Value::Str(v)) => json!({ "t": "str", "v": v }),
168        Ok(other) => json!({ "t": "str", "v": yog_data::typed::typed_string(&other).unwrap_or_default() }),
169        Err(error) => json!({ "error": error }),
170    }
171}
172
173/// The mod a file is in (the folder with its manifest, up from it).
174fn mod_root(file: &Path) -> Result<PathBuf, Value> {
175    crate::design::project::mod_root(file).ok_or_else(|| json!(format!("{} isn't in a mod", file.display())))
176}
177
178fn yres_parse(text: &str, set_and_mod: Option<(&str, &str)>) -> Result<Value, Value> {
179    let entries = yog_formats::yres::parse(text).map_err(|e| {
180        // `line:column: message` → {line, message}.
181        let line = e.split(':').next().and_then(|l| l.trim().parse::<u64>().ok());
182        json!({ "message": e, "line": line })
183    })?;
184    Ok(json!({ "entries": entries.iter().map(|e| entry_json(e, set_and_mod)).collect::<Vec<_>>() }))
185}
186
187/// An entry as a tool takes it: with its accessor, what's wrong with its
188/// name, and (a text of a known set and mod) its translation key.
189fn entry_json(e: &Entry, set_and_mod: Option<(&str, &str)>) -> Value {
190    let mut m = Map::new();
191    m.insert("name".into(), json!(e.name));
192    m.insert("type".into(), json!(e.kind.name()));
193    m.insert("value".into(), match &e.value {
194        yog_formats::yres::Value::Str(s) => json!(s),
195        yog_formats::yres::Value::Num(n) => serde_json::from_str(n).unwrap_or(Value::Null),
196        yog_formats::yres::Value::Bool(b) => json!(b),
197        yog_formats::yres::Value::Bytes(b) => json!(yog_data::typed::typed_string(&yog_data::Value::Bytes(b.clone()))),
198    });
199    m.insert("line".into(), json!(e.line));
200    m.insert("accessor".into(), json!(crate::yres::accessor_name(&e.name)));
201    if let Some(problem) = crate::yres::name_problem(&e.name) {
202        m.insert("nameProblem".into(), json!(problem));
203    }
204    if let Some(problem) = crate::yres::value_problem(e) {
205        m.insert("valueProblem".into(), json!(problem));
206    }
207    if let Some(argb) = crate::yres::color_of(e) {
208        m.insert("argb".into(), json!(argb));
209    }
210    if let (Some((set, mod_id)), Kind::String) = (set_and_mod, e.kind) {
211        let key = e.key.clone().unwrap_or_else(|| crate::yres::default_key(set, mod_id, &e.name));
212        m.insert("translationKey".into(), json!(key));
213    }
214    for (k, v) in [("comment", &e.comment), ("key", &e.key), ("event", &e.event), ("subtitle", &e.subtitle)] {
215        if let Some(v) = v {
216            m.insert(k.into(), json!(v));
217        }
218    }
219    Value::Object(m)
220}
221
222fn yres_write(entries: &Value) -> Result<Value, Value> {
223    let list = entries.as_array().ok_or_else(|| json!("`entries` is a list"))?;
224    let mut out = Vec::with_capacity(list.len());
225    for e in list {
226        let name = e.get("name").and_then(Value::as_str).ok_or_else(|| json!("an entry without `name`"))?;
227        let kind = e.get("type").and_then(Value::as_str).unwrap_or(Kind::String.name());
228        let kind = KINDS.iter().copied().find(|k| k.name() == kind).ok_or_else(|| json!(format!("{name}: no type `{kind}`")))?;
229        let value = match e.get("value") {
230            Some(Value::String(s)) if s.starts_with(yog_ids::sep::TYPED) => match yog_data::typed::classify(s) {
231                Ok(yog_data::Value::Bytes(b)) => yog_formats::yres::Value::Bytes(b),
232                _ => yog_formats::yres::Value::Str(s.clone()),
233            },
234            Some(Value::String(s)) => yog_formats::yres::Value::Str(s.clone()),
235            Some(Value::Bool(b)) => yog_formats::yres::Value::Bool(*b),
236            Some(Value::Number(n)) => yog_formats::yres::Value::Num(n.to_string()),
237            _ => return Err(json!(format!("{name}: no value"))),
238        };
239        let field = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_string);
240        out.push(Entry { name: name.to_string(), kind, value, comment: field("comment"), key: field("key"), event: field("event"), subtitle: field("subtitle"), line: 0 });
241    }
242    Ok(json!(yog_formats::yres::write(&out)))
243}
244
245/// Every kind of `.yres` entry.
246const KINDS: [Kind; 7] = [Kind::String, Kind::Image, Kind::File, Kind::Sound, Kind::Color, Kind::Number, Kind::Bool];
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    #[test]
253    fn yres_round_trips_through_the_service() {
254        let text = r##"{ "title": "Hi", "n": 3, "logo": "@bytes:iVBORw0KGgo=", "c": { "type": "color", "value": "#FF0000", "comment": "red" } }"##;
255        let parsed = dispatch("yres.parse", &json!({ "text": text })).unwrap();
256        let entries = &parsed["entries"];
257        assert_eq!(entries[2]["type"], "image");
258        assert_eq!(entries[2]["value"], "@bytes:iVBORw0KGgo=");
259        let named = dispatch("yres.parse", &json!({ "text": "{ \"cad.0_x\": \"a\", \"1a\": \"b\" }", "set": "Items", "mod": "m" })).unwrap();
260        assert_eq!(named["entries"][0]["accessor"], "cad_0_x");
261        assert!(named["entries"][0].get("nameProblem").is_none());
262        assert!(named["entries"][1]["nameProblem"].is_string());
263        assert_eq!(named["entries"][0]["translationKey"], "item.m.cad.0_x");
264        let values = dispatch("yres.parse", &json!({ "text": r##"{ "c": { "type": "color", "value": "#F00" }, "d": { "type": "color", "value": "#80FF0000" } }"## })).unwrap();
265        assert!(values["entries"][0]["valueProblem"].is_string());
266        assert_eq!(values["entries"][1]["argb"], 0x80FF_0000_u32);
267        let written = dispatch("yres.write", &json!({ "entries": entries })).unwrap();
268        let again = dispatch("yres.parse", &json!({ "text": written.as_str().unwrap() })).unwrap();
269        assert_eq!(again["entries"][3]["comment"], "red");
270        assert_eq!(dispatch("yres.parse", &json!({ "text": "{ \"a\": [] }" })).unwrap_err()["line"], 1);
271        assert!(dispatch("nope", &Value::Null).is_err());
272    }
273
274    #[test]
275    fn typed_values_are_classified_by_the_service() {
276        let r = dispatch("typed.classify", &json!({ "values": ["@item:stick", "@tag:item:c:dusts", "@Strings.a.b", "@@x", "plain", "@lang:gui.done", "@image:sprite:x", "@Item:x"] })).unwrap();
277        assert_eq!(r[0], json!({ "t": "ref", "kind": "item", "id": "minecraft:stick" }));
278        assert_eq!(r[1], json!({ "t": "tag", "kind": "item", "id": "c:dusts" }));
279        assert_eq!(r[2], json!({ "t": "res", "set": "Strings", "key": "a.b" }));
280        assert_eq!(r[3], json!({ "t": "str", "v": "@x" }));
281        assert_eq!(r[4], json!({ "t": "str", "v": "plain" }));
282        assert_eq!(r[5]["t"], "lang");
283        assert_eq!(r[6], json!({ "t": "image", "source": "sprite:x" }));
284        assert!(r[7]["error"].is_string());
285    }
286
287    #[test]
288    fn data_is_read_and_patched_through_the_service() {
289        let text = "{\n  // c\n  a: 1,\n  b: \"@item:x\"\n}";
290        assert_eq!(dispatch("data.parse", &json!({ "text": text })).unwrap(), json!(r#"{"a":1,"b":"@item:x"}"#));
291        let patched = dispatch("data.patch", &json!({ "text": text, "next": r#"{"a":2,"b":"@item:x"}"# })).unwrap();
292        assert_eq!(patched, json!(text.replace("a: 1", "a: 2")));
293        assert_eq!(dispatch("data.parse", &json!({ "text": "{ a: }" })).unwrap_err()["line"], 1);
294        assert_eq!(dispatch("data.format", &json!({ "value": "[1, 2]" })).unwrap(), json!("[1, 2]"));
295    }
296}