use crate::stdlib::json::extract_json_from_text;
pub(crate) fn locally_repair_json(text: &str) -> Option<String> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let extracted = extract_json_from_text(trimmed);
for candidate in unique_candidates([trimmed.to_string(), extracted]) {
if serde_json::from_str::<serde_json::Value>(&candidate).is_ok() {
return Some(candidate);
}
let Some(repaired) = apply_mechanical_json_fixes(&candidate) else {
continue;
};
if serde_json::from_str::<serde_json::Value>(&repaired).is_ok() {
return Some(repaired);
}
}
None
}
fn unique_candidates(items: [String; 2]) -> Vec<String> {
let mut out = Vec::with_capacity(2);
for item in items {
if !out.iter().any(|seen| seen == &item) {
out.push(item);
}
}
out
}
fn apply_mechanical_json_fixes(text: &str) -> Option<String> {
let stripped = strip_trailing_commas(text);
let quoted = quote_unquoted_keys(&stripped);
close_truncated_containers("ed)
}
fn rewrite_outside_strings(
text: &str,
mut on_code: impl FnMut(&[u8], &mut usize, &mut Vec<u8>),
) -> String {
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(text.len() + 8);
let mut in_string = false;
let mut escape = false;
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if in_string || b == b'"' {
if escape {
escape = false;
} else if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = !in_string;
}
out.push(b);
i += 1;
continue;
}
on_code(bytes, &mut i, &mut out);
}
String::from_utf8(out).expect("JSON fix-ups only insert or drop ASCII bytes")
}
fn skip_ascii_whitespace(bytes: &[u8], mut j: usize) -> usize {
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
j
}
fn strip_trailing_commas(text: &str) -> String {
rewrite_outside_strings(text, |bytes, i, out| {
let b = bytes[*i];
*i += 1;
let next = skip_ascii_whitespace(bytes, *i);
if b == b',' && matches!(bytes.get(next), Some(b'}' | b']')) {
return;
}
out.push(b);
})
}
fn quote_unquoted_keys(text: &str) -> String {
rewrite_outside_strings(text, |bytes, i, out| {
let b = bytes[*i];
out.push(b);
*i += 1;
if !matches!(b, b'{' | b',') {
return;
}
let key_start = skip_ascii_whitespace(bytes, *i);
out.extend_from_slice(&bytes[*i..key_start]);
*i = key_start;
if !bytes.get(key_start).copied().is_some_and(is_ident_start) {
return;
}
*i += 1;
while bytes.get(*i).copied().is_some_and(is_ident_continue) {
*i += 1;
}
let ident = &bytes[key_start..*i];
if bytes.get(skip_ascii_whitespace(bytes, *i)) == Some(&b':') {
out.push(b'"');
out.extend_from_slice(ident);
out.push(b'"');
} else {
out.extend_from_slice(ident);
}
})
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_continue(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn close_truncated_containers(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut stack = Vec::new();
let mut in_string = false;
let mut escape = false;
let mut last_significant = 0u8;
for &b in bytes {
if escape {
escape = false;
continue;
}
if in_string {
if b == b'\\' {
escape = true;
} else if b == b'"' {
in_string = false;
last_significant = b'"';
}
continue;
}
match b {
b'"' => in_string = true,
b'{' => stack.push(b'}'),
b'[' => stack.push(b']'),
b'}' | b']' => {
match stack.pop() {
Some(expected) if expected == b => {}
_ => return None,
}
last_significant = b;
}
b if b.is_ascii_whitespace() => {}
other => last_significant = other,
}
}
if in_string || matches!(last_significant, b':' | b',') {
return None;
}
let mut out = text.to_string();
while let Some(close) = stack.pop() {
out.push(close as char);
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn locally_repairs_trailing_comma_unquoted_key_preamble_and_truncation() {
assert_eq!(
locally_repair_json(r#"{"verdict": "PASS",}"#).as_deref(),
Some(r#"{"verdict": "PASS"}"#)
);
assert_eq!(
locally_repair_json("{verdict: \"PASS\"}").as_deref(),
Some(r#"{"verdict": "PASS"}"#)
);
assert_eq!(
locally_repair_json("Here you go: {\"verdict\": \"PASS\"} thanks.").as_deref(),
Some(r#"{"verdict": "PASS"}"#)
);
assert_eq!(
locally_repair_json("Here you go: {\"verdict\": \"PASS\",} thanks.").as_deref(),
Some(r#"{"verdict": "PASS"}"#)
);
assert_eq!(
locally_repair_json(r#"{"verdict": "PASS""#).as_deref(),
Some(r#"{"verdict": "PASS"}"#)
);
}
#[test]
fn locally_repair_preserves_non_ascii_text() {
assert_eq!(
locally_repair_json("{summary: \"café 日本\",}").as_deref(),
Some("{\"summary\": \"café 日本\"}")
);
assert_eq!(
locally_repair_json("{\"summary\": \"café\"").as_deref(),
Some("{\"summary\": \"café\"}")
);
}
#[test]
fn locally_repair_rejects_unrecoverable_and_incomplete_values() {
assert_eq!(locally_repair_json("nothing useful here at all"), None);
assert_eq!(locally_repair_json(r#"{"verdict":"#), None);
assert_eq!(locally_repair_json(r#"{"verdict": "PA"#), None);
}
}