use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Json5Error {
UnterminatedBlockComment { offset: usize },
}
impl fmt::Display for Json5Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnterminatedBlockComment { offset } => {
write!(f, "unterminated block comment '/*' at byte {offset}")
}
}
}
}
impl std::error::Error for Json5Error {}
pub fn relaxed_to_strict(src: &str) -> Result<String, Json5Error> {
Ok(rewrite_tokens(&strip_comments(src)?))
}
fn strip_comments(src: &str) -> Result<String, Json5Error> {
let bytes = src.as_bytes();
let mut out = String::with_capacity(src.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
quote @ (b'"' | b'\'') => {
let start = i;
i += 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
continue;
}
if bytes[i] == quote {
i += 1;
break;
}
i += 1;
}
let end = i.min(bytes.len());
out.push_str(&src[start..end]);
i = end;
}
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
let opened = i;
i += 2;
let closed = loop {
if i + 1 >= bytes.len() {
break false;
}
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
i += 2;
break true;
}
i += 1;
};
if !closed {
return Err(Json5Error::UnterminatedBlockComment { offset: opened });
}
out.push(' ');
}
b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'/' => {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
out.push(' ');
}
_ => {
let ch = src[i..].chars().next().expect("index is a char boundary");
out.push(ch);
i += ch.len_utf8();
}
}
}
Ok(out)
}
fn rewrite_tokens(src: &str) -> String {
let b = src.as_bytes();
let mut out = String::with_capacity(src.len() + 16);
let mut pending_comma: Option<usize> = None;
let mut i = 0;
while i < b.len() {
let c = b[i];
if c.is_ascii_whitespace() {
out.push(c as char);
i += 1;
continue;
}
match c {
b',' => {
pending_comma = Some(out.len());
out.push(',');
i += 1;
}
b'}' | b']' => {
if let Some(at) = pending_comma.take() {
out.remove(at);
}
out.push(c as char);
i += 1;
}
b'"' | b'\'' => {
pending_comma = None;
i = push_string(&mut out, src, i);
}
b'+' if b.get(i + 1).copied().is_some_and(is_ident_start_byte) => {
pending_comma = None;
i = push_word(&mut out, src, i);
}
b'0'..=b'9' | b'+' | b'-' | b'.' => {
pending_comma = None;
i = push_number(&mut out, src, i);
}
_ if is_ident_start_byte(c) => {
pending_comma = None;
i = push_word(&mut out, src, i);
}
_ => {
pending_comma = None;
let ch = src[i..].chars().next().expect("index is a char boundary");
out.push(ch);
i += ch.len_utf8();
}
}
}
out
}
fn push_string(out: &mut String, src: &str, start: usize) -> usize {
let b = src.as_bytes();
let quote = b[start];
let mut i = start + 1;
out.push('"');
while i < b.len() {
match b[i] {
b'\\' => {
i += 1;
let Some(esc) = src[i..].chars().next() else {
out.push('\\');
return b.len();
};
i += esc.len_utf8();
i = push_escape(out, src, esc, i);
}
q if q == quote => {
out.push('"');
return i + 1;
}
b'"' => {
out.push_str("\\\"");
i += 1;
}
_ => {
let ch = src[i..].chars().next().expect("index is a char boundary");
out.push(ch);
i += ch.len_utf8();
}
}
}
i
}
fn push_escape(out: &mut String, src: &str, esc: char, mut i: usize) -> usize {
let b = src.as_bytes();
match esc {
'"' | '\\' | '/' | 'b' | 'f' | 'n' | 'r' | 't' => {
out.push('\\');
out.push(esc);
}
'u' => {
out.push_str("\\u");
let mut n = 0;
while n < 4 && b.get(i).copied().is_some_and(|c| c.is_ascii_hexdigit()) {
out.push(b[i] as char);
i += 1;
n += 1;
}
}
'x' => {
let hi = b.get(i).copied().filter(|c| c.is_ascii_hexdigit());
let lo = b.get(i + 1).copied().filter(|c| c.is_ascii_hexdigit());
match (hi, lo) {
(Some(hi), Some(lo)) => {
out.push_str("\\u00");
out.push(hi as char);
out.push(lo as char);
i += 2;
}
_ => out.push_str("\\x"),
}
}
'0' => out.push_str("\\u0000"),
'v' => out.push_str("\\u000B"),
'\n' => {}
'\r' => {
if b.get(i) == Some(&b'\n') {
i += 1;
}
}
'1'..='9' => {
out.push('\\');
out.push(esc);
}
other => push_json_char(out, other),
}
i
}
fn push_json_char(out: &mut String, c: char) {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 => {
out.push_str("\\u00");
out.push(HEX[(c as usize) >> 4] as char);
out.push(HEX[(c as usize) & 0xf] as char);
}
c => out.push(c),
}
}
fn push_word(out: &mut String, src: &str, start: usize) -> usize {
let b = src.as_bytes();
let mut i = start;
if b[i] == b'+' {
i += 1;
}
let body_start = i;
while i < b.len() && is_ident_continue_byte(b[i]) {
i += 1;
}
let body = &src[body_start..i];
let mut j = i;
while j < b.len() && b[j].is_ascii_whitespace() {
j += 1;
}
if !body.is_empty() && b.get(j) == Some(&b':') {
out.push('"');
out.push_str(&src[start..i]);
out.push('"');
return i;
}
if body == "Infinity" || body == "NaN" {
out.push_str("null");
return i;
}
out.push_str(&src[start..i]);
i
}
fn push_number(out: &mut String, src: &str, start: usize) -> usize {
let b = src.as_bytes();
let mut i = start;
let negative = b[i] == b'-';
if negative || b[i] == b'+' {
i += 1;
}
if src[i..].starts_with("Infinity") {
out.push_str("null");
return i + "Infinity".len();
}
if b.get(i) == Some(&b'0') && matches!(b.get(i + 1), Some(b'x' | b'X')) {
let digits_at = i + 2;
let mut end = digits_at;
while b.get(end).copied().is_some_and(|c| c.is_ascii_hexdigit()) {
end += 1;
}
if end > digits_at
&& let Ok(v) = i128::from_str_radix(&src[digits_at..end], 16)
{
if negative {
out.push('-');
}
out.push_str(&v.to_string());
return end;
}
}
let int_at = i;
while b.get(i).copied().is_some_and(|c| c.is_ascii_digit()) {
i += 1;
}
let int_digits = &src[int_at..i];
let mut frac: Option<&str> = None;
if b.get(i) == Some(&b'.') {
let frac_at = i + 1;
let mut end = frac_at;
while b.get(end).copied().is_some_and(|c| c.is_ascii_digit()) {
end += 1;
}
frac = Some(&src[frac_at..end]);
i = end;
}
if int_digits.is_empty() && frac.is_none_or(str::is_empty) {
let ch = src[start..]
.chars()
.next()
.expect("index is a char boundary");
out.push(ch);
return start + ch.len_utf8();
}
let exp_at = i;
if matches!(b.get(i), Some(b'e' | b'E')) {
let mut end = i + 1;
if matches!(b.get(end), Some(b'+' | b'-')) {
end += 1;
}
let digits_at = end;
while b.get(end).copied().is_some_and(|c| c.is_ascii_digit()) {
end += 1;
}
if end > digits_at {
i = end;
}
}
if negative {
out.push('-');
}
out.push_str(if int_digits.is_empty() {
"0"
} else {
int_digits
});
if let Some(f) = frac {
out.push('.');
out.push_str(if f.is_empty() { "0" } else { f });
}
out.push_str(&src[exp_at..i]);
i
}
fn is_ident_start_byte(c: u8) -> bool {
c.is_ascii_alphabetic() || c == b'_' || c == b'$'
}
fn is_ident_continue_byte(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_' || c == b'$'
}
#[cfg(test)]
mod tests {
use super::{Json5Error, relaxed_to_strict};
fn strict(src: &str) -> String {
relaxed_to_strict(src).expect("no unterminated comment in this case")
}
fn parses(src: &str) -> serde_json::Value {
let out = strict(src);
serde_json::from_str(&out).unwrap_or_else(|e| panic!("{src} -> {out}: {e}"))
}
#[test]
fn strict_json_round_trips_unchanged() {
let src = r#"{"arr":{"s":2,"i":2,"e":8}}"#;
assert_eq!(strict(src), src);
}
#[test]
fn string_values_are_not_quoted_again() {
let src = r#"{"sync":{"m":"after","s":"SYS:TRIG"}}"#;
assert_eq!(strict(src), src);
}
#[test]
fn bareword_keys_are_quoted_at_every_depth() {
assert_eq!(
strict(r#"{expr:"A*B", args:[3, 1.5], prec:3}"#),
r#"{"expr":"A*B", "args":[3, 1.5], "prec":3}"#
);
}
#[test]
fn bareword_values_are_left_alone() {
assert_eq!(
strict(r#"{pipeline:true, x:null}"#),
r#"{"pipeline":true, "x":null}"#
);
}
#[test]
fn plus_prefixed_keys_are_quoted() {
assert_eq!(
strict(r#"{+channel:"VAL", +putorder:0}"#),
r#"{"+channel":"VAL", "+putorder":0}"#
);
}
#[test]
fn a_block_comment_becomes_one_space() {
assert_eq!(strict(r#"{a:1/*x*/2}"#), r#"{"a":1 2}"#);
}
#[test]
fn a_comment_between_a_key_and_its_colon_still_leaves_a_key() {
assert_eq!(strict(r#"{expr/*c*/:1}"#), r#"{"expr" :1}"#);
}
#[test]
fn a_line_comment_becomes_one_space() {
assert_eq!(strict("{a:1 // trailing\n, b:2}"), "{\"a\":1 \n, \"b\":2}");
}
#[test]
fn markers_inside_string_literals_survive() {
let src = r#"{"c":"A/*x*/B//y","i":"+literal"}"#;
assert_eq!(strict(src), src);
}
#[test]
fn an_unterminated_block_comment_is_an_error() {
assert_eq!(
relaxed_to_strict(r#"{"a":1 /* never closed"#),
Err(Json5Error::UnterminatedBlockComment { offset: 7 })
);
}
#[test]
fn an_unterminated_line_comment_is_not_an_error() {
assert_eq!(strict(r#"{"a":1} // never closed"#), r#"{"a":1} "#);
}
#[test]
fn base_s_own_single_quoted_calc_link_loads() {
let src = "{calc:{ expr:'A+5', args:5 }}";
assert_eq!(strict(src), r#"{"calc":{ "expr":"A+5", "args":5 }}"#);
assert_eq!(parses(src)["calc"]["expr"], "A+5");
}
#[test]
fn a_colon_inside_a_single_quoted_string_is_not_a_key() {
assert_eq!(
strict(r#"{pva: 'invalid:pv:name'}"#),
r#"{"pva": "invalid:pv:name"}"#
);
}
#[test]
fn the_two_quote_escape_sets_are_translated_not_swapped() {
assert_eq!(strict(r"{a:'it\'s'}"), r#"{"a":"it's"}"#);
assert_eq!(strict(r#"{a:'say "hi"'}"#), r#"{"a":"say \"hi\""}"#);
assert_eq!(parses(r"{a:'it\'s'}")["a"], "it's");
assert_eq!(parses(r#"{a:'say "hi"'}"#)["a"], "say \"hi\"");
}
#[test]
fn nul_and_vertical_tab_escapes_become_unicode_escapes() {
assert_eq!(strict(r#"{a:"x\0y"}"#), r#"{"a":"x\u0000y"}"#);
assert_eq!(strict(r#"{a:"x\vy"}"#), r#"{"a":"x\u000By"}"#);
assert_eq!(parses(r#"{a:"x\0y"}"#)["a"], "x\0y");
assert_eq!(parses(r#"{a:"x\vy"}"#)["a"], "x\u{b}y");
}
#[test]
fn hex_byte_escapes_become_unicode_escapes() {
assert_eq!(strict(r#"{a:"x\x41y"}"#), r#"{"a":"x\u0041y"}"#);
assert_eq!(parses(r#"{a:"x\x41y"}"#)["a"], "xAy");
assert_eq!(parses(r#"{a:"\x1B["}"#)["a"], "\u{1b}[");
}
#[test]
fn a_line_continuation_contributes_nothing() {
assert_eq!(strict("{a:\"x\\\ny\"}"), r#"{"a":"xy"}"#);
assert_eq!(strict("{a:\"x\\\r\ny\"}"), r#"{"a":"xy"}"#);
}
#[test]
fn an_unknown_escape_is_the_character_itself() {
assert_eq!(strict(r#"{a:"x\qy"}"#), r#"{"a":"xqy"}"#);
assert_eq!(strict(r#"{a:"x\/y"}"#), r#"{"a":"x\/y"}"#);
assert_eq!(parses(r#"{a:"x\/y"}"#)["a"], "x/y");
}
#[test]
fn unicode_escapes_pass_through() {
assert_eq!(
strict(r#"{a:"\u0041\ud83d\ude00"}"#),
r#"{"a":"\u0041\ud83d\ude00"}"#
);
assert_eq!(parses(r#"{a:"\u0041"}"#)["a"], "A");
}
#[test]
fn trailing_commas_are_dropped_before_both_closers() {
assert_eq!(strict("{a:1,}"), r#"{"a":1}"#);
assert_eq!(strict("[1,2,]"), "[1,2]");
assert_eq!(strict("{a:[1,] , }"), r#"{"a":[1] }"#);
assert_eq!(parses("{a:[1,2,],}")["a"][1], 2);
}
#[test]
fn only_a_structural_trailing_comma_is_dropped() {
assert_eq!(strict(r#"{a:"x,"}"#), r#"{"a":"x,"}"#);
assert_eq!(strict("[1,2]"), "[1,2]");
}
#[test]
fn hex_integers_become_decimal() {
assert_eq!(strict("{a:0x1F}"), r#"{"a":31}"#);
assert_eq!(strict("{a:0XfF}"), r#"{"a":255}"#);
assert_eq!(strict("{a:-0x10}"), r#"{"a":-16}"#);
assert_eq!(parses("{a:0x1F}")["a"], 31);
}
#[test]
fn a_leading_plus_is_dropped() {
assert_eq!(strict(r#"{"a":+5}"#), r#"{"a":5}"#);
assert_eq!(parses(r#"{"a":+5}"#)["a"], 5);
}
#[test]
fn a_bare_dot_gets_the_digit_strict_json_wants() {
assert_eq!(strict("{a:.5}"), r#"{"a":0.5}"#);
assert_eq!(strict("{a:-.5}"), r#"{"a":-0.5}"#);
assert_eq!(strict("{a:+.5}"), r#"{"a":0.5}"#);
assert_eq!(strict("{a:5.}"), r#"{"a":5.0}"#);
assert_eq!(parses("{a:.5}")["a"], 0.5);
assert_eq!(parses("{a:5.}")["a"], 5.0);
}
#[test]
fn exponents_survive_verbatim() {
assert_eq!(
strict("{a:1e+30, b:-2.5E-3}"),
r#"{"a":1e+30, "b":-2.5E-3}"#
);
assert_eq!(parses("{a:1e+30}")["a"], 1e30);
}
#[test]
fn non_finite_numbers_become_null() {
assert_eq!(
strict("{a:NaN, b:Infinity, c:-Infinity, d:+Infinity}"),
r#"{"a":null, "b":null, "c":null, "d":null}"#
);
assert!(parses("{a:NaN}")["a"].is_null());
}
#[test]
fn infinity_in_key_position_is_a_key() {
assert_eq!(strict("{NaN:1, Infinity:2}"), r#"{"NaN":1, "Infinity":2}"#);
}
#[test]
fn a_lone_sign_is_copied_through() {
assert_eq!(strict("{a:-}"), r#"{"a":-}"#);
assert_eq!(strict("{a:+}"), r#"{"a":+}"#);
assert_eq!(strict("{a:.}"), r#"{"a":.}"#);
}
}