use std::borrow::Cow;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Piece {
Text,
Str,
Template,
Comment,
}
fn scan(src: &str) -> Vec<(Piece, usize, usize)> {
let b = src.as_bytes();
let mut out: Vec<(Piece, usize, usize)> = Vec::new();
let mut i = 0usize;
let mut text_start = 0usize;
while i < b.len() {
let two = |j: usize, c: u8| b.get(j) == Some(&c);
match b[i] {
b'"' => {
if i > text_start {
out.push((Piece::Text, text_start, i));
}
let start = i;
i += 1;
while i < b.len() {
match b[i] {
b'\\' => i += 2,
b'"' => {
i += 1;
break;
}
_ => i += 1,
}
}
i = i.min(b.len());
out.push((Piece::Str, start, i));
text_start = i;
}
b'{' if two(i + 1, b'{') => {
if i > text_start {
out.push((Piece::Text, text_start, i));
}
let start = i;
i += 2;
while i < b.len() && !(b[i] == b'}' && two(i + 1, b'}')) {
i += 1;
}
if i < b.len() {
i += 2;
}
out.push((Piece::Template, start, i));
text_start = i;
}
b'/' if two(i + 1, b'/') => {
if i > text_start {
out.push((Piece::Text, text_start, i));
}
let start = i;
while i < b.len() && b[i] != b'\n' {
i += 1;
}
out.push((Piece::Comment, start, i));
text_start = i;
}
b'/' if two(i + 1, b'*') => {
if i > text_start {
out.push((Piece::Text, text_start, i));
}
let start = i;
i += 2;
while i < b.len() && !(b[i] == b'*' && two(i + 1, b'/')) {
i += 1;
}
if i < b.len() {
i += 2;
}
out.push((Piece::Comment, start, i));
text_start = i;
}
_ => i += 1,
}
}
if b.len() > text_start {
out.push((Piece::Text, text_start, b.len()));
}
out
}
pub fn has_comments(src: &str) -> bool {
src.contains('/') && scan(src).iter().any(|(k, _, _)| *k == Piece::Comment)
}
pub fn strip_comments(src: &str) -> String {
let mut bare = String::with_capacity(src.len());
for (kind, a, b) in scan(src) {
if kind == Piece::Comment {
bare.extend(src[a..b].chars().filter(|c| *c == '\n'));
} else {
bare.push_str(&src[a..b]);
}
}
let mut kept: Vec<&str> = Vec::new();
for (before, after) in src.lines().zip(bare.lines()) {
if after.trim().is_empty() && !before.trim().is_empty() {
continue;
}
kept.push(after.trim_end());
}
let mut out = kept.join("\n");
if src.ends_with('\n') {
out.push('\n');
}
out
}
fn drop_trailing_commas(src: &str) -> String {
let b = src.as_bytes();
let mut code = vec![false; b.len()];
for (kind, a, e) in scan(src) {
if kind == Piece::Text {
code[a..e].fill(true);
}
}
let mut out = String::with_capacity(src.len());
let mut copied = 0usize;
for i in 0..b.len() {
if b[i] != b',' || !code[i] {
continue;
}
let mut j = i + 1;
while j < b.len() && b[j].is_ascii_whitespace() {
j += 1;
}
if j < b.len() && code[j] && matches!(b[j], b'}' | b']') {
out.push_str(&src[copied..i]);
copied = i + 1;
}
}
out.push_str(&src[copied..]);
out
}
pub fn wire_body(src: &str) -> Cow<'_, str> {
if !has_comments(src) {
return Cow::Borrowed(src);
}
let stripped = strip_comments(src);
if parses_as_json(&stripped) {
return Cow::Owned(stripped);
}
let tidied = drop_trailing_commas(&stripped);
if parses_as_json(&tidied) {
return Cow::Owned(tidied);
}
Cow::Borrowed(src)
}
fn json_shape(src: &str) -> String {
let mut out = String::with_capacity(src.len());
for (kind, a, b) in scan(src) {
match kind {
Piece::Comment => {}
Piece::Template => {
let stand_in =
serde_json::Value::String(format!("\u{0}hurl-template:{}", &src[a..b]));
out.push_str(&stand_in.to_string());
}
_ => out.push_str(&src[a..b]),
}
}
out
}
fn parses_as_json(src: &str) -> bool {
serde_json::from_str::<serde_json::Value>(&json_shape(src)).is_ok()
}
pub fn bodies_equivalent(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (ja, jb) = (json_shape(a), json_shape(b));
match (
serde_json::from_str::<serde_json::Value>(&ja),
serde_json::from_str::<serde_json::Value>(&jb),
) {
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_slash_inside_a_string_is_data_not_a_comment() {
let src = r#"{"url": "https://example.net/a//b"}"#;
assert!(!has_comments(src));
assert_eq!(wire_body(src), src);
}
#[test]
fn a_line_that_is_only_a_comment_takes_its_line_with_it() {
let src = "{\n // who\n \"id\": 1\n}";
assert_eq!(strip_comments(src), "{\n \"id\": 1\n}");
}
#[test]
fn a_trailing_comment_leaves_the_line_it_sat_on() {
let src = "{\n \"id\": 1 // the caller\n}";
assert_eq!(strip_comments(src), "{\n \"id\": 1\n}");
}
#[test]
fn blank_lines_the_user_typed_are_their_spacing_and_stay() {
let src = "{\n \"a\": 1,\n\n // note\n \"b\": 2\n}";
assert_eq!(strip_comments(src), "{\n \"a\": 1,\n\n \"b\": 2\n}");
}
#[test]
fn a_block_comment_spanning_lines_removes_all_of_them() {
let src = "{\n /* one\n two */\n \"id\": 1\n}";
assert_eq!(strip_comments(src), "{\n \"id\": 1\n}");
}
#[test]
fn a_block_comment_inside_a_line_leaves_the_rest_of_it() {
let src = r#"{"a": /* why */ 1}"#;
assert_eq!(strip_comments(src), r#"{"a": 1}"#);
}
#[test]
fn templates_survive_stripping_and_still_let_the_body_parse() {
let src = "{\n // who\n \"id\": {{user_id}},\n \"u\": \"https://{{host}}/x\"\n}";
let out = wire_body(src);
assert_eq!(
out,
"{\n \"id\": {{user_id}},\n \"u\": \"https://{{host}}/x\"\n}"
);
}
#[test]
fn a_plain_text_body_containing_slashes_is_left_alone() {
let src = "see http://x for details // and this is not a comment";
assert_eq!(wire_body(src), src);
assert_eq!(strip_comments("a // b"), "a");
}
#[test]
fn an_unterminated_string_does_not_swallow_the_rest_as_code() {
let src = "{\"a\": \"unclosed";
assert_eq!(wire_body(src), src);
}
#[test]
fn reformatting_and_reordering_keys_is_not_a_divergence() {
assert!(bodies_equivalent(
"{\"a\":1,\"b\":2}",
"{\n \"b\": 2,\n \"a\": 1\n}"
));
}
#[test]
fn a_changed_value_is_a_divergence() {
assert!(!bodies_equivalent("{\"a\":1}", "{\"a\":2}"));
}
#[test]
fn two_different_templates_are_not_the_same_value() {
assert!(!bodies_equivalent(
"{\"id\": {{alice}}}",
"{\"id\": {{bob}}}"
));
assert!(bodies_equivalent(
"{\"id\": {{alice}}}",
"{\n \"id\": {{alice}}\n}"
));
}
#[test]
fn comments_do_not_change_what_a_body_says() {
assert!(bodies_equivalent(
"{\n // note\n \"a\": 1\n}",
"{\"a\":1}"
));
}
#[test]
fn whitespace_matters_in_a_body_that_is_not_json() {
assert!(!bodies_equivalent("<a>x y</a>", "<a>x y</a>"));
assert!(!bodies_equivalent("hello world", "hello world"));
assert!(!bodies_equivalent("hello world", "goodbye world"));
assert!(bodies_equivalent("<a>x y</a>", "<a>x y</a>"));
}
#[test]
fn commenting_out_a_last_field_does_not_strand_its_comma() {
assert_eq!(
wire_body("{\n \"a\": 1,\n // \"b\": 2\n}").as_ref(),
"{\n \"a\": 1\n}"
);
assert_eq!(
wire_body("{\n \"a\": 1, // note\n}").as_ref(),
"{\n \"a\": 1\n}"
);
assert_eq!(wire_body("[\n 1,\n // 2\n]").as_ref(), "[\n 1\n]");
}
#[test]
fn a_comma_inside_a_string_is_not_punctuation() {
assert_eq!(
wire_body("{\n \"a\": \"x,\", // note\n \"b\": \"y,}\"\n}").as_ref(),
"{\n \"a\": \"x,\",\n \"b\": \"y,}\"\n}"
);
assert_eq!(
wire_body("{\n \"a\": [{{x}}, {{y}}] // note\n}").as_ref(),
"{\n \"a\": [{{x}}, {{y}}]\n}"
);
}
#[test]
fn a_body_that_is_not_json_keeps_its_slashes() {
let src = "query { a } // not a comment, this is text";
assert_eq!(wire_body(src).as_ref(), src);
assert!(matches!(wire_body(src), Cow::Borrowed(_)));
}
#[test]
fn a_body_with_no_comments_is_returned_without_copying_it() {
let src = r#"{"a": 1}"#;
assert!(matches!(wire_body(src), Cow::Borrowed(_)));
}
}