Skip to main content

yog_data/
patch.rs

1//! Editing Yog Data text in place: [`patch`] changes a document to hold a
2//! new value and keeps everything that didn't change as it was — comments,
3//! key order, number spelling, layout. What changed is written by
4//! [`format`], the way Yog's editors write a value.
5
6use crate::text::{Spans, write_string};
7use crate::value::{Map, Value};
8
9/// A line no longer than this is written on one line by [`format`].
10const ONE_LINE: usize = 72;
11/// One indent step.
12const INDENT: &str = "  ";
13/// Numbers are written to this many decimals.
14const DECIMALS: f64 = 10_000.0;
15
16/// `value` as Yog's editors write it at indent `level` (two spaces each):
17/// short lists of numbers and short objects of scalars on one line
18/// (`"uv": [0, 0, 16, 16]`), the rest one entry a line.
19pub fn format(value: &Value, level: usize) -> String {
20    if let Some(one) = inline(value) {
21        return one;
22    }
23    let pad = INDENT.repeat(level + 1);
24    let end = INDENT.repeat(level);
25    match value {
26        Value::List(items) => {
27            let body: Vec<String> = items.iter().map(|v| format!("{pad}{}", format(v, level + 1))).collect();
28            format!("[\n{}\n{end}]", body.join(",\n"))
29        }
30        Value::Map(map) => {
31            let body: Vec<String> = map.iter().map(|(k, v)| format!("{pad}{}: {}", quoted(k), format(v, level + 1))).collect();
32            format!("{{\n{}\n{end}}}", body.join(",\n"))
33        }
34        other => scalar(other).unwrap_or_default(),
35    }
36}
37
38fn quoted(s: &str) -> String {
39    let mut out = String::with_capacity(s.len() + 2);
40    write_string(s, &mut out);
41    out
42}
43
44fn number(n: f64) -> String {
45    let rounded = (n * DECIMALS).round() / DECIMALS;
46    if rounded.fract() == 0.0 && rounded.abs() < 1e15 { format!("{}", rounded as i64) } else { rounded.to_string() }
47}
48
49fn scalar(v: &Value) -> Option<String> {
50    match v {
51        Value::Null => Some("null".into()),
52        Value::Bool(b) => Some(b.to_string()),
53        Value::Int(n) => Some(n.to_string()),
54        Value::Float(f) if f.is_finite() => Some(number(*f)),
55        Value::Float(_) => Some("null".into()),
56        Value::List(_) | Value::Map(_) => None,
57        // As the source has it: a typed value (`@item:…`) or an escaped `@@`, unread.
58        Value::Str(s) => Some(quoted(s)),
59        other => Some(crate::text::to_string(other)),
60    }
61}
62
63fn inline(v: &Value) -> Option<String> {
64    if let Some(s) = scalar(v) {
65        return Some(s);
66    }
67    let out = match v {
68        Value::List(items) if items.is_empty() => return Some("[]".into()),
69        Value::List(items) => {
70            if !items.iter().all(|x| matches!(x, Value::Int(_) | Value::Float(_) | Value::Bool(_))) {
71                return None;
72            }
73            format!("[{}]", items.iter().filter_map(scalar).collect::<Vec<_>>().join(", "))
74        }
75        Value::Map(map) if map.is_empty() => return Some("{}".into()),
76        Value::Map(map) => {
77            let mut parts = Vec::with_capacity(map.len());
78            for (k, x) in map.iter() {
79                if matches!(x, Value::Map(_)) {
80                    return None;
81                }
82                parts.push(format!("{}: {}", quoted(k), inline(x)?));
83            }
84            format!("{{ {} }}", parts.join(", "))
85        }
86        _ => return None,
87    };
88    (out.len() <= ONE_LINE).then_some(out)
89}
90
91/// `value` as compact JSON for a tool: maps in their order, strings as the
92/// source has them (a typed value's text unread).
93pub fn to_json(value: &Value) -> String {
94    let mut out = String::new();
95    json_into(value, &mut out);
96    out
97}
98
99fn json_into(value: &Value, out: &mut String) {
100    match value {
101        Value::List(items) => {
102            out.push('[');
103            for (i, v) in items.iter().enumerate() {
104                if i > 0 {
105                    out.push(',');
106                }
107                json_into(v, out);
108            }
109            out.push(']');
110        }
111        Value::Map(map) => {
112            out.push('{');
113            for (i, (k, v)) in map.iter().enumerate() {
114                if i > 0 {
115                    out.push(',');
116                }
117                write_string(k, out);
118                out.push(':');
119                json_into(v, out);
120            }
121            out.push('}');
122        }
123        Value::Str(s) => write_string(s, out),
124        Value::Float(f) if f.is_finite() => out.push_str(&f.to_string()),
125        Value::Float(_) => out.push_str("null"),
126        other => out.push_str(&crate::text::to_string(other)),
127    }
128}
129
130/// Whether two values are the same, a number however it's spelled (`1` = `1.0`).
131pub fn same(a: &Value, b: &Value) -> bool {
132    match (a, b) {
133        (Value::Int(_) | Value::Float(_), Value::Int(_) | Value::Float(_)) => a.as_f64() == b.as_f64(),
134        (Value::List(x), Value::List(y)) => x.len() == y.len() && x.iter().zip(y).all(|(a, b)| same(a, b)),
135        (Value::Map(x), Value::Map(y)) => x.len() == y.len() && x.iter().zip(y.iter()).all(|((ka, a), (kb, b))| ka == kb && same(a, b)),
136        _ => a == b,
137    }
138}
139
140/// `text` changed to hold `next`, the rest as it was. Maps and lists are
141/// edited entry by entry (a kept entry keeps the comments before it; a
142/// removed one takes them along). Text that doesn't parse is written anew.
143pub fn patch(text: &str, next: &Value) -> String {
144    let Ok(doc) = crate::text::parse(text) else {
145        return format!("{}\n", format(next, 0));
146    };
147    let (start, end) = range(&doc.spans);
148    format!("{}{}{}", &text[..start], rewrite(text, &doc.value, &doc.spans, next), &text[end..])
149}
150
151fn range(s: &Spans) -> (usize, usize) {
152    (s.span.start as usize, s.span.end as usize)
153}
154
155/// The indent of the line `at` is on.
156fn line_indent(text: &str, at: usize) -> &str {
157    let start = text[..at].rfind('\n').map_or(0, |i| i + 1);
158    let line = &text[start..at];
159    &line[..line.len() - line.trim_start_matches([' ', '\t']).len()]
160}
161
162fn level_of(indent: &str) -> usize {
163    indent.chars().map(|c| if c == '\t' { INDENT.len() } else { 1 }).sum::<usize>().div_ceil(INDENT.len())
164}
165
166/// Past whitespace and comments from `i`.
167fn skip_blank(text: &str, mut i: usize) -> usize {
168    let b = text.as_bytes();
169    loop {
170        match (b.get(i), b.get(i + 1)) {
171            (Some(b' ' | b'\t' | b'\n' | b'\r'), _) => i += 1,
172            (Some(b'/'), Some(b'/')) => i = text[i..].find('\n').map_or(text.len(), |n| i + n),
173            (Some(b'/'), Some(b'*')) => i = text[i + 2..].find("*/").map_or(text.len(), |n| i + 2 + n + 2),
174            _ => return i,
175        }
176    }
177}
178
179/// A member as rewritten: its text, then what followed it (a comment) — the comma goes between.
180struct Member {
181    body: String,
182    trail: String,
183}
184
185fn join(out: &[Member], trailing_comma: bool) -> String {
186    let mut s = String::new();
187    for (i, m) in out.iter().enumerate() {
188        s.push_str(&m.body);
189        if i + 1 < out.len() || trailing_comma {
190            s.push(',');
191        }
192        s.push_str(&m.trail);
193    }
194    s
195}
196
197/// A container's members as text: each one's lead (the comments above it)
198/// and trail (after it, up to its comma); whether the last has a comma;
199/// what's left before the bracket.
200struct Members {
201    segments: Vec<(String, String)>,
202    comma: bool,
203    tail: String,
204}
205
206fn members(text: &str, node: (usize, usize), spans: &[(usize, usize)]) -> Members {
207    let mut segments = Vec::with_capacity(spans.len());
208    let mut at = node.0 + 1;
209    let mut comma = false;
210    for &(start, end) in spans {
211        let lead = text[at..start].to_string();
212        let after = skip_blank(text, end);
213        comma = text.as_bytes().get(after) == Some(&b',');
214        // A comment on the member's own line stays with it; the rest goes to the next one.
215        let eol = text[end..].find('\n').map(|n| end + n);
216        let stop = if comma { after } else { eol.map_or(after, |e| after.min(e)) };
217        let mut trail = text[end..stop].to_string();
218        at = if comma { after + 1 } else { stop };
219        if comma {
220            let eol = text[at..].find('\n').map_or(text.len(), |n| at + n);
221            let rest = &text[at..eol];
222            let t = rest.trim();
223            let comment = t.starts_with("//") || (t.starts_with("/*") && t.ends_with("*/") && t.matches("*/").count() == 1);
224            if comment && eol < node.1 {
225                trail.push_str(rest);
226                at = eol;
227            }
228        }
229        segments.push((lead, trail));
230    }
231    Members { segments, comma, tail: text[at..node.1 - 1].to_string() }
232}
233
234/// The new text of the value `old` (at `spans`) holding `next`.
235fn rewrite(text: &str, old: &Value, spans: &Spans, next: &Value) -> String {
236    let node = range(spans);
237    if same(old, next) {
238        return text[node.0..node.1].to_string();
239    }
240    let level = level_of(line_indent(text, node.0));
241    let fresh = || format(next, level);
242    if !text[node.0..node.1].contains('\n') {
243        return fresh();
244    }
245    match (old, next) {
246        (Value::Map(old), Value::Map(next)) if !old.is_empty() && spans.children.len() == old.len() => rewrite_map(text, node, old, spans, next),
247        (Value::List(old), Value::List(next)) if !old.is_empty() && spans.children.len() == old.len() => rewrite_list(text, node, old, spans, next),
248        _ => fresh(),
249    }
250}
251
252fn rewrite_map(text: &str, node: (usize, usize), old: &Map, spans: &Spans, next: &Map) -> String {
253    let keys: Vec<&str> = old.keys().collect();
254    let key_start = |c: &Spans| c.key.map_or(c.span.start, |k| k.start) as usize;
255    let ranges: Vec<(usize, usize)> = spans.children.iter().map(|c| (key_start(c), c.span.end as usize)).collect();
256    let parts = members(text, node, &ranges);
257    let pad = line_indent(text, ranges[0].0);
258    let gone = |i: usize| i < keys.len() && next.get(keys[i]).is_none();
259    let mut out = Vec::with_capacity(next.len());
260    // The old entry last written: a new key right after it, in the place of one that's gone, is that one renamed.
261    let mut last: Option<usize> = None;
262    for (k, v) in next.iter() {
263        let after = last.map_or(0, |l| l + 1);
264        let found = keys.iter().position(|o| *o == k);
265        let renamed = found.is_none() && gone(after);
266        let Some(i) = found.or(renamed.then_some(after)) else {
267            out.push(Member { body: format!("\n{pad}{}: {}", quoted(k), format(v, level_of(pad))), trail: String::new() });
268            continue;
269        };
270        last = Some(i);
271        let child = &spans.children[i];
272        let value_start = child.span.start as usize;
273        let key = if renamed {
274            let key_end = child.key.map_or(value_start, |s| s.end as usize);
275            format!("{}{}", quoted(k), &text[key_end..value_start])
276        } else {
277            text[ranges[i].0..value_start].to_string()
278        };
279        let old_value = old.value_at(i).unwrap_or(&Value::Null);
280        let (lead, trail) = &parts.segments[i];
281        out.push(Member { body: format!("{lead}{key}{}", rewrite(text, old_value, child, v)), trail: trail.clone() });
282    }
283    format!("{{{}{}}}", join(&out, parts.comma), parts.tail)
284}
285
286fn rewrite_list(text: &str, node: (usize, usize), old: &[Value], spans: &Spans, next: &[Value]) -> String {
287    let ranges: Vec<(usize, usize)> = spans.children.iter().map(range).collect();
288    let parts = members(text, node, &ranges);
289    let pad = line_indent(text, ranges[0].0);
290    let item = |i: usize, v: &Value| {
291        let (lead, trail) = &parts.segments[i];
292        Member { body: format!("{lead}{}", rewrite(text, &old[i], &spans.children[i], v)), trail: trail.clone() }
293    };
294    let out: Vec<Member> = if next.len() == old.len() {
295        next.iter().enumerate().map(|(i, v)| item(i, v)).collect()
296    } else {
297        // Added or removed items: the others keep their text.
298        let mut from = 0;
299        next.iter()
300            .map(|v| match (from..old.len()).find(|&k| same(&old[k], v)) {
301                Some(i) => {
302                    from = i + 1;
303                    item(i, v)
304                }
305                None => Member { body: format!("\n{pad}{}", format(v, level_of(pad))), trail: String::new() },
306            })
307            .collect()
308    };
309    format!("[{}{}]", join(&out, parts.comma), parts.tail)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::text::parse_value;
316
317    fn edit(text: &str, change: impl FnOnce(&mut Map)) -> String {
318        let Value::Map(mut v) = parse_value(text).unwrap() else { panic!("a map") };
319        change(&mut v);
320        let next = Value::Map(v);
321        let out = patch(text, &next);
322        assert!(same(&parse_value(&out).unwrap(), &next), "{out}");
323        out
324    }
325
326    const FILE: &str = r#"// A block.
327{
328  "id": "lamp", // its id
329  /* What it is. */
330  "block": {
331    "hardness": 4.0,
332    "light": 15
333  },
334  "states": {
335    "properties": {
336      // On or off.
337      "lit": ["false", "true"],
338    },
339  },
340}
341"#;
342
343    fn map_at<'a>(m: &'a mut Map, key: &str) -> &'a mut Map {
344        match m.get_mut(key) {
345            Some(Value::Map(inner)) => inner,
346            _ => panic!("no map `{key}`"),
347        }
348    }
349
350    #[test]
351    fn what_didnt_change_keeps_its_text() {
352        assert_eq!(edit(FILE, |_| {}), FILE);
353        let out = edit(FILE, |m| {
354            map_at(m, "block").insert("light", Value::Int(7));
355        });
356        assert_eq!(out, FILE.replace("\"light\": 15", "\"light\": 7"));
357    }
358
359    #[test]
360    fn a_new_key_goes_after_the_others() {
361        let out = edit(FILE, |m| {
362            map_at(m, "block").insert("sound", Value::Str("stone".into()));
363        });
364        assert!(out.contains("\"light\": 15,\n    \"sound\": \"stone\"\n  },"), "{out}");
365        assert!(out.contains("// its id"));
366    }
367
368    #[test]
369    fn a_removed_key_takes_its_comments() {
370        let out = edit(FILE, |m| {
371            m.remove("block");
372        });
373        assert!(!out.contains("What it is."), "{out}");
374        assert!(out.contains("\"id\": \"lamp\", // its id") && out.contains("// On or off."), "{out}");
375        assert_eq!(edit("{\n  \"a\": 1, // one\n  \"b\": 2\n}", |m| {
376            m.remove("b");
377        }), "{\n  \"a\": 1 // one\n}");
378        assert_eq!(edit("{\n  \"a\": 1 // one\n}", |m| {
379            m.insert("b", Value::Int(2));
380        }), "{\n  \"a\": 1, // one\n  \"b\": 2\n}");
381    }
382
383    #[test]
384    fn a_renamed_key_keeps_its_place_and_comments() {
385        let out = edit(FILE, |m| {
386            let props = map_at(map_at(m, "states"), "properties");
387            let lit = props.remove("lit").unwrap_or(Value::Null);
388            props.insert("on", lit);
389        });
390        assert!(out.contains("// On or off.\n      \"on\": [\"false\", \"true\"],"), "{out}");
391        let text = "{\n  a: 1,\n  b: 2,\n  c: 3\n}";
392        let mut next = Map::new();
393        for (k, v) in [("a", 1), ("x", 2), ("c", 3)] {
394            next.insert(k, Value::Int(v));
395        }
396        assert_eq!(patch(text, &Value::Map(next)), "{\n  a: 1,\n  \"x\": 2,\n  c: 3\n}");
397    }
398
399    #[test]
400    fn list_items_added_and_removed_keep_the_others() {
401        let text = "{\n  \"list\": [\n    // first\n    { \"a\": 1 },\n    { \"b\": 2 }\n  ]\n}";
402        let list = |m: &mut Map| match m.get_mut("list") {
403            Some(Value::List(l)) => std::mem::take(l),
404            _ => Vec::new(),
405        };
406        let out = edit(text, |m| {
407            let mut l = list(m);
408            l.push(parse_value("{ c: 3 }").unwrap());
409            m.insert("list", Value::List(l));
410        });
411        assert!(out.contains("// first"), "{out}");
412        let out = edit(text, |m| {
413            let mut l = list(m);
414            l.remove(0);
415            m.insert("list", Value::List(l));
416        });
417        assert!(!out.contains("first") && out.contains("{ \"b\": 2 }"), "{out}");
418    }
419
420    #[test]
421    fn number_keys_keep_their_order() {
422        let text = "{\n  \"rotation\": {\n    \"0\": [0, 0, 0], // rest\n    \"0.5\": [45, 0, 0],\n    \"1\": [0, 0, 0]\n  }\n}";
423        let out = edit(text, |m| {
424            map_at(m, "rotation").insert("0.5", parse_value("[90, 0, 0]").unwrap());
425        });
426        assert_eq!(out, text.replace("[45, 0, 0]", "[90, 0, 0]"));
427    }
428
429    #[test]
430    fn json_keeps_order_and_source_strings() {
431        let v = parse_value(r#"{ "1": "@item:minecraft:stick", "0.5": [1.5, null, true] }"#).unwrap();
432        assert_eq!(to_json(&v), r#"{"1":"@item:minecraft:stick","0.5":[1.5,null,true]}"#);
433    }
434
435    #[test]
436    fn short_values_go_on_one_line() {
437        let v = parse_value(r#"{ "uv": [0, 0, 16, 16.5], "faces": { "north": { "texture": "x" } } }"#).unwrap();
438        assert_eq!(format(&v, 0), "{\n  \"uv\": [0, 0, 16, 16.5],\n  \"faces\": {\n    \"north\": { \"texture\": \"x\" }\n  }\n}");
439        assert_eq!(patch("{ oops", &Value::Int(1)), "1\n");
440    }
441}