use super::json_comments::{Piece, bodies_equivalent, parses_as_json, scan, wire_body};
const INDENT: &str = " ";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
Open,
Close,
Comma,
Colon,
Value,
LineComment,
BlockComment,
}
struct Tok<'a> {
kind: Kind,
text: &'a str,
breaks: usize,
col: usize,
}
fn tokens(src: &str) -> Vec<Tok<'_>> {
let b = src.as_bytes();
let mut out: Vec<Tok<'_>> = Vec::new();
let mut breaks = 0usize;
let mut line_start = 0usize;
for (piece, a, e) in scan(src) {
let text = &src[a..e];
if piece == Piece::Text {
let mut i = a;
while i < e {
if b[i].is_ascii_whitespace() {
if b[i] == b'\n' {
breaks += 1;
line_start = i + 1;
}
i += 1;
continue;
}
let kind = match b[i] {
b'{' | b'[' => Kind::Open,
b'}' | b']' => Kind::Close,
b',' => Kind::Comma,
b':' => Kind::Colon,
_ => Kind::Value,
};
let start = i;
if kind == Kind::Value {
while i < e
&& !b[i].is_ascii_whitespace()
&& !matches!(b[i], b'{' | b'[' | b'}' | b']' | b',' | b':')
{
i += 1;
}
} else {
i += 1;
}
out.push(Tok {
kind,
text: &src[start..i],
breaks,
col: src[line_start..start].chars().count(),
});
breaks = 0;
}
continue;
}
let kind = match piece {
Piece::Comment if text.starts_with("//") => Kind::LineComment,
Piece::Comment => Kind::BlockComment,
_ => Kind::Value,
};
out.push(Tok {
kind,
text,
breaks,
col: src[line_start..a].chars().count(),
});
breaks = 0;
if let Some(nl) = text.rfind('\n') {
line_start = a + nl + 1;
}
}
out
}
enum Gap {
None,
Space,
Line { blank: bool },
}
fn lay_out(toks: &[Tok<'_>]) -> String {
let mut out = String::new();
let mut depth = 0usize;
for (i, t) in toks.iter().enumerate() {
if t.kind == Kind::Close {
depth = depth.saturating_sub(1);
}
let gap = if i == 0 {
Gap::None
} else {
let prev = toks[i - 1].kind;
if prev == Kind::LineComment {
Gap::Line {
blank: t.breaks >= 2,
}
} else {
match t.kind {
Kind::Comma | Kind::Colon => Gap::None,
Kind::Close if prev == Kind::Open => Gap::None,
Kind::LineComment | Kind::BlockComment if t.breaks == 0 => Gap::Space,
_ if prev == Kind::Colon => Gap::Space,
_ => Gap::Line {
blank: t.breaks >= 2,
},
}
}
};
match gap {
Gap::None => {}
Gap::Space => out.push(' '),
Gap::Line { blank } => {
out.push('\n');
if blank {
out.push('\n');
}
out.push_str(&INDENT.repeat(depth));
}
}
write_token(&mut out, t);
if t.kind == Kind::Open {
depth += 1;
}
}
out
}
fn write_token(out: &mut String, t: &Tok<'_>) {
if t.kind != Kind::BlockComment || !t.text.contains('\n') {
out.push_str(t.text);
return;
}
let line_start = out.rfind('\n').map_or(0, |nl| nl + 1);
let open_col = out[line_start..].chars().count();
let mut lines = t.text.split('\n');
if let Some(first) = lines.next() {
out.push_str(first);
}
for line in lines {
out.push('\n');
let body = line.trim_start();
if body.is_empty() {
continue;
}
let extra = (line.len() - body.len()).saturating_sub(t.col);
out.push_str(&" ".repeat(open_col + extra));
out.push_str(body);
}
}
fn comments(src: &str) -> Vec<String> {
scan(src)
.into_iter()
.filter(|(k, _, _)| *k == Piece::Comment)
.map(|(_, a, b)| {
src[a..b]
.lines()
.map(str::trim)
.collect::<Vec<_>>()
.join("\n")
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Prettified {
Changed(String),
Unchanged,
NotJson,
}
pub fn prettify_json(src: &str) -> Prettified {
if src.trim().is_empty() {
return Prettified::NotJson;
}
if !parses_as_json(&wire_body(src)) {
return Prettified::NotJson;
}
let mut out = lay_out(&tokens(src));
if src.ends_with('\n') {
out.push('\n');
}
if !bodies_equivalent(&wire_body(src), &wire_body(&out)) || comments(&out) != comments(src) {
return Prettified::NotJson;
}
if out == src {
Prettified::Unchanged
} else {
Prettified::Changed(out)
}
}
pub fn map_cursor(src: &str, out: &str, at: usize) -> usize {
let wanted = src.as_bytes()[..at.min(src.len())]
.iter()
.filter(|b| !b.is_ascii_whitespace())
.count();
if wanted == 0 {
return 0;
}
let mut seen = 0usize;
let mut landed = out.len();
for (i, b) in out.bytes().enumerate() {
if !b.is_ascii_whitespace() {
seen += 1;
if seen == wanted {
landed = i + 1;
break;
}
}
}
while landed < out.len() && !out.is_char_boundary(landed) {
landed += 1;
}
landed
}
#[cfg(test)]
mod tests {
use super::*;
fn pretty(src: &str) -> String {
match prettify_json(src) {
Prettified::Changed(s) => s,
other => panic!("expected a reformat, got {other:?}"),
}
}
#[test]
fn a_minified_body_gains_indentation() {
assert_eq!(
pretty(r#"{"a":1,"b":[1,2],"c":{"d":true}}"#),
"{\n \"a\": 1,\n \"b\": [\n 1,\n 2\n ],\n \"c\": {\n \"d\": true\n }\n}"
);
}
#[test]
fn an_empty_object_or_array_stays_on_one_line() {
assert_eq!(
pretty(r#"{"a":{},"b":[]}"#),
"{\n \"a\": {},\n \"b\": []\n}"
);
}
#[test]
fn numbers_are_copied_not_re_encoded() {
let src = r#"{"a":1.50,"b":1e3,"c":12345678901234567890,"d":-0.0}"#;
let out = pretty(src);
for atom in ["1.50", "1e3", "12345678901234567890", "-0.0"] {
assert!(out.contains(atom), "{atom} was rewritten: {out}");
}
}
#[test]
fn duplicate_keys_and_key_order_are_left_alone() {
let out = pretty(r#"{"z":1,"a":2,"z":3}"#);
assert_eq!(out, "{\n \"z\": 1,\n \"a\": 2,\n \"z\": 3\n}");
}
#[test]
fn a_bare_template_survives_and_does_not_block_formatting() {
assert_eq!(
pretty("{\"n\":{{ COUNT }},\"u\":\"https://{{host}}/x\"}"),
"{\n \"n\": {{ COUNT }},\n \"u\": \"https://{{host}}/x\"\n}"
);
}
#[test]
fn punctuation_inside_strings_and_templates_is_data() {
assert_eq!(
pretty(r#"{"s":"a,b{c}d","t":"[]"}"#),
"{\n \"s\": \"a,b{c}d\",\n \"t\": \"[]\"\n}"
);
}
#[test]
fn a_comment_on_its_own_line_keeps_its_own_line() {
assert_eq!(
pretty("{\n// who is asking\n\"id\":1}"),
"{\n // who is asking\n \"id\": 1\n}"
);
}
#[test]
fn a_trailing_comment_stays_at_the_end_of_its_line() {
assert_eq!(
pretty("{\"id\":1, // the caller\n\"n\":2}"),
"{\n \"id\": 1, // the caller\n \"n\": 2\n}"
);
}
#[test]
fn a_comma_is_never_swallowed_by_the_comment_before_it() {
let out = pretty("{\"a\":1 // note\n,\"b\":2}");
assert!(
!out.contains("// note,"),
"the comma was commented out: {out}"
);
assert!(parses_as_json(&wire_body(&out)), "unreadable result: {out}");
}
#[test]
fn a_commented_out_last_field_still_formats() {
let out = pretty("{\"a\":1,\n// \"b\": 2\n}");
assert_eq!(out, "{\n \"a\": 1,\n // \"b\": 2\n}");
}
#[test]
fn a_blank_line_between_fields_is_kept_as_grouping() {
assert_eq!(
pretty("{\"a\":1,\n\n\n\"b\":2}"),
"{\n \"a\": 1,\n\n \"b\": 2\n}",
"several blank lines collapse to one, but the grouping survives"
);
}
#[test]
fn a_multi_line_block_comment_keeps_its_column_of_stars() {
let out = pretty("{\n/* one\n * two\n */\n\"a\":1}");
assert_eq!(out, "{\n /* one\n * two\n */\n \"a\": 1\n}");
}
#[test]
fn a_body_that_is_already_laid_out_this_way_reports_no_change() {
let src = "{\n \"a\": 1\n}";
assert_eq!(prettify_json(src), Prettified::Unchanged);
}
#[test]
fn a_trailing_newline_is_kept_and_one_that_was_absent_is_not_added() {
assert!(pretty("{\"a\":1}\n").ends_with("}\n"));
assert!(!pretty("{\"a\":1}").ends_with('\n'));
}
#[test]
fn anything_that_is_not_json_is_refused_rather_than_mangled() {
for src in [
"",
" \n ",
"query { user { name } }",
"<order><id>1</id></order>",
"{\"a\": ",
"plain text // not a comment",
] {
assert_eq!(
prettify_json(src),
Prettified::NotJson,
"should have refused: {src:?}"
);
}
}
#[test]
fn formatting_is_idempotent() {
for src in [
r#"{"a":1,"b":[1,{"c":2}],"d":{}}"#,
"{\n// lead\n\"a\":1, // trail\n\n\"b\":[]}",
"{\"n\":{{ COUNT }}}",
"[1,2,3]",
"{\n/* one\n * two\n */\n\"a\":1}",
"{\n\"a\":1, /* one\n * two\n */\n\"b\":2}",
] {
let once = pretty(src);
assert_eq!(
prettify_json(&once),
Prettified::Unchanged,
"second pass moved it: {once}"
);
}
}
#[test]
fn a_trailing_block_comment_keeps_its_column_of_stars() {
let out = pretty("{\n\"a\":1, /* one\n * two\n */\n\"b\":2}");
assert_eq!(
out,
"{\n \"a\": 1, /* one\n * two\n */\n \"b\": 2\n}"
);
let col = |needle: &str| {
out.lines()
.find(|l| l.contains(needle))
.unwrap()
.find(needle)
};
assert_eq!(col("/*"), col("* two"));
assert_eq!(col("/*"), col("*/"));
assert_eq!(prettify_json(&out), Prettified::Unchanged);
}
#[test]
fn the_cursor_lands_on_the_same_character_it_was_on() {
let src = r#"{"a":1,"bee":2}"#;
let out = pretty(src);
let at = src.find("bee").unwrap() + 1;
let mapped = map_cursor(src, &out, at);
assert_eq!(&out[mapped - 1..mapped], "b");
assert_eq!(map_cursor(src, &out, 0), 0, "the start stays the start");
assert_eq!(
map_cursor(src, &out, src.len()),
out.len(),
"and the end stays the end"
);
}
#[test]
fn mapping_a_cursor_never_lands_inside_a_character() {
let src = "{\"n\":\"café ☕\",\"b\":2}";
let out = pretty(src);
for at in 0..=src.len() {
let mapped = map_cursor(src, &out, at);
assert!(out.is_char_boundary(mapped), "split a character at {at}");
}
}
#[test]
fn the_result_always_says_what_the_input_said() {
for src in [
r#"{"a":1,"b":[1,2]}"#,
"{\n// c\n\"a\":1}",
"{\"u\":\"https://x/a//b\"}",
] {
let out = pretty(src);
assert!(bodies_equivalent(&wire_body(src), &wire_body(&out)));
}
}
}