diff --git a/Cargo.lock b/Cargo.lock index 1d5bc9c..89eb1f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,9 +290,9 @@ checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" [[package]] name = "jsonc-parser" -version = "0.33.2" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ff5a48f48971be8e762a6ff955725a0802b6e46c441057992da5a673db9fd3a" +checksum = "0ecded084f9b9a718d39668a42657c32253663d1f6afb3fbb9134f0305f73f48" dependencies = [ "unicode-width", ] diff --git a/Cargo.toml b/Cargo.toml index 0c28257..adf22a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ tracing = ["dprint-core/tracing"] [dependencies] dprint-core = { version = "0.69.1", features = ["formatting"] } dprint-core-macros = "0.1.0" -jsonc-parser = { version = "0.33.2", features = ["cst", "error_unicode_width"] } +jsonc-parser = { version = "0.34.0", features = ["cst", "error_unicode_width"] } serde = { version = "1.0.144", features = ["derive"] } serde_json = { version = "1.0", optional = true } text_lines = "0.6.0" diff --git a/src/format_text.rs b/src/format_text.rs index d10645e..4e684c0 100644 --- a/src/format_text.rs +++ b/src/format_text.rs @@ -293,6 +293,24 @@ mod tests { assert_eq!(format(&config, "\"a\\n\\\\\u{7F}\""), "\"a\\n\\\\\u{7F}\"\n"); } + #[test] + fn handles_json5_escapes_in_strings() { + // the spec files can't reliably express these since editors change raw line breaks and tabs + let config = ConfigurationBuilder::new().build(); + // line continuations mean nothing + assert_eq!(format(&config, "\"a \\\nb\""), "\"a b\"\n"); + assert_eq!(format(&config, "'a \\\r\nb \\\rc'"), "\"a b c\"\n"); + // an escaped control character is that character + assert_eq!(format(&config, "\"a\\\tb\""), "\"a\\tb\"\n"); + // an escaped backslash before a control character isn't an escape of it + assert_eq!(format(&config, "\"a\\\\\nb\""), "\"a\\\\\\nb\"\n"); + // single quoted strings with escaped quotes + assert_eq!(format(&config, r#"'\"'"#), r#""\"""#.to_string() + "\n"); + assert_eq!(format(&config, r#"'a\\\'b"c'"#), r#""a\\'b\"c""#.to_string() + "\n"); + // other json5 escapes are left alone + assert_eq!(format(&config, r#""\x41\v\0""#), r#""\x41\v\0""#.to_string() + "\n"); + } + fn format(config: &Configuration, text: &str) -> String { let output = format_text(Path::new("/file.json"), text, config) .unwrap() diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 9f7e570..7261631 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -281,16 +281,10 @@ const COMMA_SC: &StringContainer = sc!(","); fn gen_string_lit<'a>(node: &'a StringLit, context: &mut Context<'a, '_>) -> PrintItems { let text = node.text(context.text); - let is_double_quotes = text.starts_with('"'); + let is_single_quoted = text.starts_with('\''); let mut items = PrintItems::new(); - let text = &text[1..text.len() - 1]; items.push_sc(DOUBLE_QUOTE_SC); - if is_double_quotes { - items.push_str(&escape_control_chars(text)); - } else { - let text = text.replace("\\'", "'").replace('"', "\\\""); - items.push_str(&escape_control_chars(&text)); - } + items.push_str(&to_double_quoted_text(&text[1..text.len() - 1], is_single_quoted)); items.push_sc(DOUBLE_QUOTE_SC); items } @@ -817,29 +811,57 @@ fn should_break_up_single_line(ranged: &impl Ranged, context: &Context) -> bool && range.width() > (context.config.line_width * 2) as usize } -/// Escapes control characters (U+0000 through U+001F), which JSON doesn't allow -/// unescaped in strings. The parser accepts them and the printer can't handle raw newlines. -fn escape_control_chars(text: &str) -> Cow<'_, str> { +/// Gets the text between a string's quotes for writing it in double quotes with the same value. +/// +/// Control characters (U+0000 through U+001F), which JSON doesn't allow unescaped in strings, +/// get escaped since the parser accepts them and the printer can't handle raw newlines. That +/// includes a control character escaped the JSON5 way (ex. a backslash then a tab), and a JSON5 +/// line continuation (a backslash then a newline) is removed since it means nothing. +fn to_double_quoted_text(text: &str, is_single_quoted: bool) -> Cow<'_, str> { // checking bytes is enough since every byte of a multi-byte utf-8 char is at least 0x80 - if !text.bytes().any(|b| b < 0x20) { + let needs_change = |b: u8| b < 0x20 || is_single_quoted && matches!(b, b'"' | b'\''); + if !text.bytes().any(needs_change) { return Cow::Borrowed(text); } let mut result = String::with_capacity(text.len() + 8); - for c in text.chars() { + let mut chars = text.chars(); + while let Some(c) = chars.next() { match c { - '\n' => result.push_str("\\n"), - '\r' => result.push_str("\\r"), - '\t' => result.push_str("\\t"), - '\u{08}' => result.push_str("\\b"), - '\u{0C}' => result.push_str("\\f"), - '\u{00}'..='\u{1F}' => write!(result, "\\u{:04x}", c as u32).unwrap(), + '\\' => match chars.next() { + Some('\n') => {} + Some('\r') => { + if chars.clone().next() == Some('\n') { + chars.next(); + } + } + Some(c @ '\u{00}'..='\u{1F}') => push_escaped_control_char(&mut result, c), + Some('\'') if is_single_quoted => result.push('\''), + Some(c) => { + result.push('\\'); + result.push(c); + } + None => result.push('\\'), + }, + '"' if is_single_quoted => result.push_str("\\\""), + '\u{00}'..='\u{1F}' => push_escaped_control_char(&mut result, c), _ => result.push(c), } } Cow::Owned(result) } +fn push_escaped_control_char(result: &mut String, c: char) { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\u{08}' => result.push_str("\\b"), + '\u{0C}' => result.push_str("\\f"), + _ => write!(result, "\\u{:04x}", c as u32).unwrap(), + } +} + fn sc_items(sc: &'static StringContainer) -> PrintItems { let mut items = PrintItems::new(); items.push_sc(sc); diff --git a/tests/specs/json5/Json5_All.txt b/tests/specs/json5/Json5_All.txt new file mode 100644 index 0000000..d9d66de --- /dev/null +++ b/tests/specs/json5/Json5_All.txt @@ -0,0 +1,17 @@ +== should keep json5 numbers as written == +{a: .5, b: +5, c: 5., d: Infinity, e: -Infinity, f: NaN, g: 0xFF, h: 5.e3} + +[expect] +{ "a": .5, "b": +5, "c": 5., "d": Infinity, "e": -Infinity, "f": NaN, "g": 0xFF, "h": 5.e3 } + +== should quote json5 property names == +{$key: 1, _a$b: 2, true: 3, false: 4, null: 5, NaN: 6, Infinity: 7} + +[expect] +{ "$key": 1, "_a$b": 2, "true": 3, "false": 4, "null": 5, "NaN": 6, "Infinity": 7 } + +== should keep json5 string escapes == +["\x41\v\0", 'it\'s'] + +[expect] +["\x41\v\0", "it's"]