Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions src/format_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
60 changes: 41 additions & 19 deletions src/generation/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions tests/specs/json5/Json5_All.txt
Original file line number Diff line number Diff line change
@@ -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"]
Loading