Skip to main content

mars_schema/
edit.rs

1//! Canonical YAML emission for editors that splice bytes into authored frontmatter.
2//!
3//! One rule decides how every string, key or value, is spelled: plain when it is a
4//! safe identifier-like word, JSON double-quoted otherwise. Quoted strings escape
5//! every character YAML parsers disagree about, so the output reads back as the
6//! same value in `serde_yaml` and in other YAML libraries.
7
8use serde_json::Number;
9
10use crate::ordered::{MAX_SAFE_INTEGER, OrderedValue as Value};
11
12/// A value that cannot be written as YAML without changing it.
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14pub enum EmitError {
15    #[error("integer {0} is beyond ±(2^53 − 1) and cannot cross to JavaScript exactly")]
16    IntegerTooLarge(String),
17    #[error("duplicate frontmatter key `{0}`")]
18    DuplicateKey(String),
19    #[error("mapping key of {0} characters is over the 1024 YAML allows for an implicit key")]
20    KeyTooLong(usize),
21}
22
23/// Longest implicit mapping key (as written, quotes included) YAML parsers accept.
24const MAX_KEY_CHARS: usize = 1024;
25
26/// Emit one JSON-compatible value for the mapping key at column `indent`.
27///
28/// A scalar is one line with no leading newline. A collection starts with `\n`, and
29/// its children sit at column `indent + 2`. Output never ends in a newline. Splice
30/// the result directly after `key:` (a scalar after one space).
31pub fn emit_value(value: &Value, indent: usize) -> Result<String, EmitError> {
32    check(value)?;
33    Ok(emit(value, indent))
34}
35
36/// Emit a whole frontmatter mapping: the block between the `---` fences, keys in
37/// order, ending in exactly one newline (empty for no keys).
38pub fn rewrite_block(values: &[(String, Value)]) -> Result<String, EmitError> {
39    let mut seen = std::collections::HashSet::new();
40    let mut out = String::new();
41    for (key, value) in values {
42        if !seen.insert(key.as_str()) {
43            return Err(EmitError::DuplicateKey(key.clone()));
44        }
45        check_key(key)?;
46        check(value)?;
47        push_pair(&mut out, key, value, 0);
48        out.push('\n');
49    }
50    Ok(out)
51}
52
53fn check(value: &Value) -> Result<(), EmitError> {
54    match value {
55        Value::Number(n) => check_number(n),
56        Value::Array(values) => values.iter().try_for_each(check),
57        Value::Object(map) => map.iter().try_for_each(|(key, value)| {
58            check_key(key)?;
59            check(value)
60        }),
61        _ => Ok(()),
62    }
63}
64
65fn check_key(key: &str) -> Result<(), EmitError> {
66    let written = scalar(key).chars().count();
67    if written > MAX_KEY_CHARS {
68        Err(EmitError::KeyTooLong(written))
69    } else {
70        Ok(())
71    }
72}
73
74/// Refuses integers JavaScript cannot hold exactly. A float of any finite magnitude is fine:
75/// it is written so that both parsers read the same float back.
76fn check_number(n: &Number) -> Result<(), EmitError> {
77    let too_large = n
78        .as_i64()
79        .is_some_and(|v| v.unsigned_abs() > MAX_SAFE_INTEGER)
80        || n.as_u64().is_some_and(|v| v > MAX_SAFE_INTEGER);
81    if too_large {
82        Err(EmitError::IntegerTooLarge(n.to_string()))
83    } else {
84        Ok(())
85    }
86}
87
88/// A float that is whole and safe is written as an integer, since JavaScript cannot tell
89/// `1.0` from `1`; any other float keeps `serde_json`'s shortest form (`1e300`, `1.5e-7`),
90/// which YAML 1.1 and 1.2 parsers read back as the same float.
91fn number(n: &Number) -> String {
92    match n.as_f64() {
93        Some(v) if n.is_f64() && v.fract() == 0.0 && v.abs() <= MAX_SAFE_INTEGER as f64 => {
94            (v as i64).to_string()
95        }
96        _ => n.to_string(),
97    }
98}
99
100fn push_pair(out: &mut String, key: &str, value: &Value, column: usize) {
101    out.push_str(&scalar(key));
102    out.push(':');
103    let rendered = emit(value, column);
104    if !rendered.starts_with('\n') {
105        out.push(' ');
106    }
107    out.push_str(&rendered);
108}
109
110fn emit(value: &Value, indent: usize) -> String {
111    let child = indent + 2;
112    match value {
113        Value::Null => "null".into(),
114        Value::Bool(v) => v.to_string(),
115        Value::Number(v) => number(v),
116        Value::String(v) => scalar(v),
117        Value::Array(values) if values.is_empty() => "[]".into(),
118        Value::Object(values) if values.is_empty() => "{}".into(),
119        Value::Array(values) => {
120            let mut out = String::new();
121            for item in values {
122                out.push('\n');
123                out.push_str(&" ".repeat(child));
124                out.push('-');
125                let rendered = emit(item, child);
126                match rendered.strip_prefix('\n') {
127                    // A collection item continues on the dash line (`- a: 1`).
128                    Some(block) => {
129                        out.push(' ');
130                        out.push_str(&block[child + 2..]);
131                    }
132                    None => {
133                        out.push(' ');
134                        out.push_str(&rendered);
135                    }
136                }
137            }
138            out
139        }
140        Value::Object(values) => {
141            let mut out = String::new();
142            for (key, value) in values {
143                out.push('\n');
144                out.push_str(&" ".repeat(child));
145                push_pair(&mut out, key, value, child);
146            }
147            out
148        }
149    }
150}
151
152/// Spell a string as one YAML scalar on one line.
153fn scalar(s: &str) -> String {
154    if is_plain_safe(s) {
155        return s.to_owned();
156    }
157    let mut out = String::with_capacity(s.len() + 2);
158    out.push('"');
159    for c in s.chars() {
160        match c {
161            '"' => out.push_str("\\\""),
162            '\\' => out.push_str("\\\\"),
163            '\n' => out.push_str("\\n"),
164            '\r' => out.push_str("\\r"),
165            '\t' => out.push_str("\\t"),
166            c if !is_printable(c) => {
167                let n = c as u32;
168                if n <= 0xff {
169                    out.push_str(&format!("\\x{n:02x}"));
170                } else {
171                    out.push_str(&format!("\\u{n:04x}"));
172                }
173            }
174            c => out.push(c),
175        }
176    }
177    out.push('"');
178    out
179}
180
181/// YAML's printable set, minus line separators and the byte-order mark that some
182/// parsers treat as structure.
183fn is_printable(c: char) -> bool {
184    matches!(c, ' '..='~' | '\u{a0}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')
185        && !matches!(c, '\u{2028}' | '\u{2029}' | '\u{feff}')
186}
187
188/// Plain scalars are limited to `[A-Za-z_][A-Za-z0-9_./-]*` that no YAML 1.1 or 1.2
189/// reader turns into a boolean, null or number.
190fn is_plain_safe(s: &str) -> bool {
191    let mut chars = s.chars();
192    let Some(first) = chars.next() else {
193        return false;
194    };
195    (first.is_ascii_alphabetic() || first == '_')
196        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '/' | '-'))
197        && !matches!(
198            s.to_ascii_lowercase().as_str(),
199            "true"
200                | "false"
201                | "yes"
202                | "no"
203                | "on"
204                | "off"
205                | "y"
206                | "n"
207                | "null"
208                | "inf"
209                | "nan"
210                | "infinity"
211        )
212}