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