jaml/
formatter.rs

1//! Format a [`Value`] into JAML text.
2//!
3//! The main entry point is [`format()`] for standard use cases.
4//! For custom formatting options, use [`format_with_opts()`] with [`Options`].
5//!
6//! ```
7//! use jaml::{Value, format};
8//!
9//! let value = Value::String("hello".to_string());
10//! assert_eq!(format(&value), "\"hello\"");
11//!
12//! // Custom formatting with advanced options
13//! use jaml::formatter::{Options, format_with_opts};
14//! let opts = Options::new().with_sort_keys(false);
15//! let formatted = format_with_opts(&value, &opts);
16//! ```
17
18use std::collections::BTreeMap;
19
20use time::{format_description, macros::format_description as fd};
21
22use crate::{Binary, Value};
23
24/// Formatting options and configuration.
25mod options;
26pub use options::{BinaryEncoding, Options, QuoteStyle, TimestampPrecision};
27
28/// Formats a JAML [`Value`] into an indentation-based string.
29///
30/// JAML format is inherently indentation-based (like YAML), so there's no compact vs pretty distinction.
31/// All output uses 2-space indentation by design.
32pub fn format(value: &Value) -> String {
33    format_impl(value, &Options::default(), 0, false)
34}
35
36/// Formats a JAML [`Value`] with custom formatting options.
37pub fn format_with_opts(value: &Value, opts: &Options) -> String {
38    format_impl(value, opts, 0, false)
39}
40
41fn format_impl(value: &Value, opts: &Options, depth: usize, inline: bool) -> String {
42    match value {
43        Value::Null => "null".to_string(),
44        Value::Bool(b) => b.to_string(),
45        Value::Int(i) => format_int(*i, opts),
46        Value::Float(f) => format_float(*f, opts),
47        Value::String(s) => {
48            let quote = match opts.quote_style {
49                QuoteStyle::Double => '"',
50                QuoteStyle::Single => '\'',
51                QuoteStyle::PreferDouble => {
52                    if s.contains('"') && !s.contains('\'') {
53                        '\''
54                    } else {
55                        '"'
56                    }
57                }
58            };
59            format_string(s, quote, opts.escape_unicode)
60        }
61        Value::Binary(b) => format_binary(b, opts.binary_encoding),
62        Value::Timestamp(t) => format_timestamp(t, opts),
63        Value::List(items) => format_list(items, opts, depth, inline),
64        Value::Map(map) => format_map(map, opts, depth, inline),
65    }
66}
67
68fn format_int(i: i64, opts: &Options) -> String {
69    if opts.leading_plus && i >= 0 {
70        format!("+{}", i)
71    } else {
72        i.to_string()
73    }
74}
75
76fn format_float(f: f64, opts: &Options) -> String {
77    let base_string = if f.is_infinite() {
78        if f.is_sign_negative() {
79            "-inf".to_string()
80        } else {
81            "inf".to_string()
82        }
83    } else if f.is_nan() {
84        "nan".to_string()
85    } else if f.fract() == 0.0 && f.abs() < 1e15 {
86        // Ensure we always have a decimal point to distinguish from integers
87        format!("{:.1}", f)
88    } else {
89        f.to_string()
90    };
91
92    // Add leading plus for positive numbers (including +inf, but not nan)
93    if opts.leading_plus && !f.is_nan() && !base_string.starts_with('-') {
94        format!("+{}", base_string)
95    } else {
96        base_string
97    }
98}
99
100const TIMESTAMP_FORMAT_SECONDS: &[format_description::FormatItem<'static>] = fd!(
101    "[year]-[month]-[day]T[hour]:[minute]:[second][offset_hour sign:mandatory]:[offset_minute]"
102);
103
104const TIMESTAMP_FORMAT_MILLIS: &[format_description::FormatItem<'static>] = fd!(
105    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3][offset_hour sign:mandatory]:[offset_minute]"
106);
107
108const TIMESTAMP_FORMAT_MICROS: &[format_description::FormatItem<'static>] = fd!(
109    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6][offset_hour sign:mandatory]:[offset_minute]"
110);
111
112const TIMESTAMP_FORMAT_NANOS: &[format_description::FormatItem<'static>] = fd!(
113    "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:9][offset_hour sign:mandatory]:[offset_minute]"
114);
115
116fn format_timestamp(t: &crate::Timestamp, opts: &Options) -> String {
117    // Select format descriptor based on precision
118    let format: &[format_description::FormatItem<'_>] = match opts.timestamp_precision {
119        TimestampPrecision::Auto => {
120            // Use RFC3339 which includes fractional seconds when present
121            let formatted = t
122                .format(&time::format_description::well_known::Rfc3339)
123                .unwrap_or_else(|_| t.to_string());
124
125            // RFC3339 uses Z for UTC, convert to +00:00 if needed
126            let final_str = if !opts.use_zulu && formatted.ends_with('Z') {
127                let mut s = formatted;
128                s.pop();
129                s.push_str("+00:00");
130                s
131            } else {
132                formatted
133            };
134            return format!("ts\"{}\"", final_str);
135        }
136        TimestampPrecision::Seconds => TIMESTAMP_FORMAT_SECONDS,
137        TimestampPrecision::Milliseconds => TIMESTAMP_FORMAT_MILLIS,
138        TimestampPrecision::Microseconds => TIMESTAMP_FORMAT_MICROS,
139        TimestampPrecision::Nanoseconds => TIMESTAMP_FORMAT_NANOS,
140    };
141
142    // Custom formats output +00:00, convert to Z if needed
143    let formatted = t.format(format).unwrap_or_else(|_| t.to_string());
144    let final_str = if opts.use_zulu && formatted.ends_with("+00:00") {
145        let mut s = formatted;
146        s.truncate(s.len() - 6);
147        s.push('Z');
148        s
149    } else {
150        formatted
151    };
152
153    format!("ts\"{}\"", final_str)
154}
155
156fn format_string(s: &str, quote: char, escape_unicode: bool) -> String {
157    let mut result = String::with_capacity(s.len() + 2);
158    result.push(quote);
159
160    for ch in s.chars() {
161        match ch {
162            '"' if quote == '"' => result.push_str("\\\""),
163            '\'' if quote == '\'' => result.push_str("\\'"),
164            '\\' => result.push_str("\\\\"),
165            '/' => result.push_str("\\/"),
166            '\n' => result.push_str("\\n"),
167            '\t' => result.push_str("\\t"),
168            '\r' => result.push_str("\\r"),
169            '\x08' => result.push_str("\\b"),
170            '\x0C' => result.push_str("\\f"),
171            c if c.is_control() => {
172                use std::fmt::Write;
173                write!(&mut result, "\\u{:04x}", c as u32).unwrap();
174            }
175            c if escape_unicode && !c.is_ascii() => {
176                use std::fmt::Write;
177                let code = c as u32;
178                if code <= 0xFFFF {
179                    // BMP character - single escape sequence
180                    write!(&mut result, "\\u{:04x}", code).unwrap();
181                } else {
182                    // Non-BMP character - use UTF-16 surrogate pair
183                    let adjusted = code - 0x10000;
184                    let high = 0xD800 + (adjusted >> 10);
185                    let low = 0xDC00 + (adjusted & 0x3FF);
186                    write!(&mut result, "\\u{:04x}\\u{:04x}", high, low).unwrap();
187                }
188            }
189            c => result.push(c),
190        }
191    }
192
193    result.push(quote);
194    result
195}
196
197fn format_binary(binary: &Binary, encoding: BinaryEncoding) -> String {
198    match encoding {
199        BinaryEncoding::Base64 => {
200            use base64::{Engine as _, engine::general_purpose};
201            let encoded = general_purpose::STANDARD.encode(&binary.0);
202            format!("b64\"{}\"", encoded)
203        }
204        BinaryEncoding::Hex => {
205            let hex: String = binary.0.iter().map(|b| format!("{:02x}", b)).collect();
206            format!("hex\"{}\"", hex)
207        }
208    }
209}
210
211fn format_list(items: &[Value], opts: &Options, depth: usize, inline: bool) -> String {
212    if items.is_empty() {
213        // Use inline syntax for empty lists
214        return "[]".to_string();
215    }
216
217    let indent = "  ".repeat(depth);
218    let mut result = String::new();
219
220    for (i, item) in items.iter().enumerate() {
221        if i > 0 || !inline {
222            result.push_str(&indent);
223        }
224        result.push_str("- ");
225
226        // Check if the item can be written inline or needs nesting
227        match item {
228            Value::List(items) if !items.is_empty() => {
229                // Non-empty nested lists need to go on the next indented line
230                result.push('\n');
231                result.push_str(&format_impl(item, opts, depth + 1, false));
232            }
233            Value::Map(m) if !m.is_empty() => {
234                // Non-empty nested maps need to go on the next indented line
235                result.push('\n');
236                result.push_str(&format_impl(item, opts, depth + 1, false));
237            }
238            _ => {
239                // Primitives, empty lists [], and empty maps {} can go inline after the dash
240                result.push_str(&format_impl(item, opts, depth + 1, true));
241                result.push('\n');
242            }
243        }
244    }
245
246    result
247}
248
249fn format_map(map: &BTreeMap<String, Value>, opts: &Options, depth: usize, inline: bool) -> String {
250    if map.is_empty() {
251        // Use inline syntax for empty maps
252        return "{}".to_string();
253    }
254
255    let indent = "  ".repeat(depth);
256    let mut result = String::new();
257
258    let entries: Vec<_> = if opts.sort_keys {
259        let mut sorted: Vec<_> = map.iter().collect();
260        sorted.sort_by_key(|(k, _)| *k);
261        sorted
262    } else {
263        map.iter().collect()
264    };
265
266    for (i, (key, value)) in entries.iter().enumerate() {
267        if i > 0 || !inline {
268            result.push_str(&indent);
269        }
270
271        // Format key (possibly unquoted)
272        if opts.unquoted_keys && can_be_unquoted(key) {
273            result.push_str(key);
274        } else {
275            let quote = match opts.quote_style {
276                QuoteStyle::Double => '"',
277                QuoteStyle::Single => '\'',
278                QuoteStyle::PreferDouble => {
279                    if key.contains('"') && !key.contains('\'') {
280                        '\''
281                    } else {
282                        '"'
283                    }
284                }
285            };
286            result.push_str(&format_string(key, quote, opts.escape_unicode));
287        }
288
289        result.push(':');
290
291        // Check if the value can be written inline or needs nesting
292        match value {
293            Value::List(items) if !items.is_empty() => {
294                // Non-empty lists need to go on the next indented line
295                result.push('\n');
296                result.push_str(&format_impl(value, opts, depth + 1, false));
297            }
298            Value::Map(m) if !m.is_empty() => {
299                // Non-empty maps need to go on the next indented line
300                result.push('\n');
301                result.push_str(&format_impl(value, opts, depth + 1, false));
302            }
303            _ => {
304                // Primitive values, empty lists [], and empty maps {} can go inline
305                result.push(' ');
306                result.push_str(&format_impl(value, opts, depth + 1, true));
307                result.push('\n');
308            }
309        }
310    }
311
312    result
313}
314
315fn can_be_unquoted(key: &str) -> bool {
316    if key.is_empty() {
317        return false;
318    }
319
320    // Reserved keywords cannot be unquoted
321    if matches!(key, "null" | "true" | "false" | "inf" | "nan") {
322        return false;
323    }
324
325    let mut chars = key.chars();
326    let first = chars.next().unwrap();
327
328    // Must start with letter or underscore
329    if !first.is_ascii_alphabetic() && first != '_' {
330        return false;
331    }
332
333    // Rest must be alphanumeric or underscore
334    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
335}