fuzzy_parser/sanitize.rs
1//! JSON sanitization for malformed LLM output
2//!
3//! This module provides pre-processing functions to fix common JSON syntax errors
4//! that LLMs often produce, making the JSON parseable before fuzzy repair.
5//!
6//! # Supported Fixes
7//!
8//! - **Trailing commas**: `{"a": 1,}` → `{"a": 1}`
9//! - **Missing closing braces**: `{"a": 1` → `{"a": 1}`
10//! - **Missing closing brackets**: `["a"` → `["a"]`
11//! - **Mismatched closing delimiters**: `{"a": [1}` → `{"a": [1]}`
12//! - **Stray closing delimiters**: `{"a": 1}}` → `{"a": 1}`
13//!
14//! # Example
15//!
16//! ```
17//! use fuzzy_parser::sanitize_json;
18//!
19//! // Fix trailing comma
20//! let input = r#"{"name": "test",}"#;
21//! let fixed = sanitize_json(input);
22//! assert_eq!(fixed, r#"{"name": "test"}"#);
23//!
24//! // Fix missing closing brace
25//! let input = r#"{"name": "test""#;
26//! let fixed = sanitize_json(input);
27//! assert_eq!(fixed, r#"{"name": "test"}"#);
28//!
29//! // Combined with fuzzy repair
30//! use fuzzy_parser::{repair_tagged_enum_json, TaggedEnumSchema, FuzzyOptions};
31//!
32//! let schema = TaggedEnumSchema::new("type", &["Action"], |_| Some(&["name"][..]));
33//! let malformed = r#"{"type": "Action", "name": "test",}"#;
34//!
35//! let sanitized = sanitize_json(malformed);
36//! let result = repair_tagged_enum_json(&sanitized, &schema, &FuzzyOptions::default()).unwrap();
37//! assert_eq!(result.repaired["name"], "test");
38//! ```
39//!
40//! # Design Notes
41//!
42//! This function performs **best-effort** sanitization. It handles common cases
43//! but does not attempt to fix all possible JSON errors. For severely malformed
44//! input, the result may still fail to parse.
45//!
46//! The function is designed to be:
47//! - **Safe**: Never produces worse output than input
48//! - **Fast**: Single-pass processing where possible
49//! - **Predictable**: Only fixes well-defined error patterns
50
51/// Sanitize malformed JSON string
52///
53/// Fixes common syntax errors that LLMs produce:
54/// - Trailing commas before `}` or `]`
55/// - Missing closing braces `}` or brackets `]`
56/// - Mismatched closing delimiters (the expected closer is inserted first)
57/// - Stray closing delimiters with no matching opener (dropped)
58///
59/// # Arguments
60///
61/// * `input` - The potentially malformed JSON string
62///
63/// # Returns
64///
65/// A sanitized JSON string that may be parseable by serde_json.
66///
67/// # Examples
68///
69/// ```
70/// use fuzzy_parser::sanitize_json;
71///
72/// // Trailing comma in object
73/// assert_eq!(sanitize_json(r#"{"a": 1,}"#), r#"{"a": 1}"#);
74///
75/// // Trailing comma in array
76/// assert_eq!(sanitize_json(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
77///
78/// // Missing closing brace
79/// assert_eq!(sanitize_json(r#"{"a": 1"#), r#"{"a": 1}"#);
80///
81/// // Missing closing bracket
82/// assert_eq!(sanitize_json(r#"["a", "b""#), r#"["a", "b"]"#);
83///
84/// // Nested structures
85/// assert_eq!(
86/// sanitize_json(r#"{"items": [1, 2,], "name": "test",}"#),
87/// r#"{"items": [1, 2], "name": "test"}"#
88/// );
89///
90/// // Already valid JSON passes through unchanged
91/// assert_eq!(sanitize_json(r#"{"a": 1}"#), r#"{"a": 1}"#);
92/// ```
93pub fn sanitize_json(input: &str) -> String {
94 let trimmed = input.trim();
95 if trimmed.is_empty() {
96 return String::new();
97 }
98
99 // Step 1: Fix missing closing delimiters first
100 let with_delimiters = fix_missing_delimiters(trimmed);
101
102 // Step 2: Remove trailing commas (now that delimiters exist)
103 remove_trailing_commas(&with_delimiters)
104}
105
106/// Remove trailing commas before `}` or `]`
107///
108/// Handles commas inside strings correctly (does not remove them).
109fn remove_trailing_commas(input: &str) -> String {
110 let mut result = String::with_capacity(input.len());
111 let mut chars = input.chars().peekable();
112 let mut in_string = false;
113 let mut escape_next = false;
114
115 while let Some(c) = chars.next() {
116 if escape_next {
117 result.push(c);
118 escape_next = false;
119 continue;
120 }
121
122 match c {
123 '\\' if in_string => {
124 result.push(c);
125 escape_next = true;
126 }
127 '"' => {
128 in_string = !in_string;
129 result.push(c);
130 }
131 ',' if !in_string => {
132 // Look ahead to see if this comma is followed by } or ]
133 // Skip whitespace when looking ahead
134 let mut peek_iter = chars.clone();
135 let next_non_ws = loop {
136 match peek_iter.next() {
137 Some(ws) if ws.is_whitespace() => continue,
138 other => break other,
139 }
140 };
141
142 if matches!(next_non_ws, Some('}') | Some(']')) {
143 // Skip this trailing comma
144 continue;
145 }
146 result.push(c);
147 }
148 _ => {
149 result.push(c);
150 }
151 }
152 }
153
154 result
155}
156
157/// Fix missing or mismatched closing braces `}` and brackets `]`
158///
159/// Rebuilds the input while tracking opening delimiters on a stack, so that
160/// the output nesting is always balanced:
161///
162/// - A closer matching the stack top passes through unchanged.
163/// - A closer that mismatches the stack top first closes the intervening
164/// open scopes (`{"a": [1}` → `{"a": [1]}`). This recovers the common LLM
165/// failure of closing an outer scope while forgetting an inner one.
166/// - A stray closer with no matching opener on the stack is dropped
167/// (`{"a":1}}` → `{"a":1}`), since keeping it can only produce trailing
168/// garbage that serde_json rejects.
169/// - Any scopes still open at end of input are closed in reverse order.
170fn fix_missing_delimiters(input: &str) -> String {
171 let mut result = String::with_capacity(input.len() + 4);
172 let mut in_string = false;
173 let mut escape_next = false;
174
175 // Stack to track opening delimiters: '{' or '['
176 let mut stack: Vec<char> = Vec::new();
177
178 for c in input.chars() {
179 if escape_next {
180 escape_next = false;
181 result.push(c);
182 continue;
183 }
184
185 match c {
186 '\\' if in_string => {
187 escape_next = true;
188 result.push(c);
189 }
190 '"' => {
191 in_string = !in_string;
192 result.push(c);
193 }
194 '{' | '[' if !in_string => {
195 stack.push(c);
196 result.push(c);
197 }
198 '}' | ']' if !in_string => {
199 let opener = if c == '}' { '{' } else { '[' };
200 if stack.contains(&opener) {
201 // Close intervening (mismatched) open scopes so the
202 // output nesting stays valid, then emit this closer.
203 while let Some(&top) = stack.last() {
204 if top == opener {
205 stack.pop();
206 break;
207 }
208 stack.pop();
209 result.push(closer_for(top));
210 }
211 result.push(c);
212 }
213 // No matching opener anywhere: drop the stray closer.
214 }
215 _ => {
216 result.push(c);
217 }
218 }
219 }
220
221 // Close unclosed string if any
222 if in_string {
223 result.push('"');
224 }
225
226 // Append missing closing delimiters in reverse order
227 for &opener in stack.iter().rev() {
228 result.push(closer_for(opener));
229 }
230
231 result
232}
233
234/// The closing delimiter that matches an opening `{` or `[`
235fn closer_for(opener: char) -> char {
236 if opener == '{' {
237 '}'
238 } else {
239 ']'
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 // =========================================================================
248 // Trailing Comma Tests
249 // =========================================================================
250
251 #[test]
252 fn test_trailing_comma_object() {
253 assert_eq!(sanitize_json(r#"{"a": 1,}"#), r#"{"a": 1}"#);
254 }
255
256 #[test]
257 fn test_trailing_comma_array() {
258 assert_eq!(sanitize_json(r#"[1, 2, 3,]"#), r#"[1, 2, 3]"#);
259 }
260
261 #[test]
262 fn test_trailing_comma_nested_object() {
263 assert_eq!(
264 sanitize_json(r#"{"outer": {"inner": 1,},}"#),
265 r#"{"outer": {"inner": 1}}"#
266 );
267 }
268
269 #[test]
270 fn test_trailing_comma_nested_array() {
271 assert_eq!(sanitize_json(r#"[[1, 2,], [3,],]"#), r#"[[1, 2], [3]]"#);
272 }
273
274 #[test]
275 fn test_trailing_comma_mixed() {
276 assert_eq!(
277 sanitize_json(r#"{"items": [1, 2,], "name": "test",}"#),
278 r#"{"items": [1, 2], "name": "test"}"#
279 );
280 }
281
282 #[test]
283 fn test_trailing_comma_with_whitespace() {
284 assert_eq!(sanitize_json(r#"{"a": 1 , }"#), r#"{"a": 1 }"#);
285 assert_eq!(sanitize_json("{\n \"a\": 1,\n}"), "{\n \"a\": 1\n}");
286 }
287
288 #[test]
289 fn test_comma_in_string_preserved() {
290 // Commas inside strings should NOT be removed
291 assert_eq!(
292 sanitize_json(r#"{"msg": "hello, world"}"#),
293 r#"{"msg": "hello, world"}"#
294 );
295 assert_eq!(sanitize_json(r#"{"msg": "a,}"}"#), r#"{"msg": "a,}"}"#);
296 }
297
298 #[test]
299 fn test_no_trailing_comma() {
300 assert_eq!(sanitize_json(r#"{"a": 1}"#), r#"{"a": 1}"#);
301 assert_eq!(sanitize_json(r#"[1, 2, 3]"#), r#"[1, 2, 3]"#);
302 }
303
304 // =========================================================================
305 // Missing Delimiter Tests
306 // =========================================================================
307
308 #[test]
309 fn test_missing_closing_brace() {
310 assert_eq!(sanitize_json(r#"{"a": 1"#), r#"{"a": 1}"#);
311 }
312
313 #[test]
314 fn test_missing_closing_bracket() {
315 assert_eq!(sanitize_json(r#"["a", "b""#), r#"["a", "b"]"#);
316 }
317
318 #[test]
319 fn test_missing_multiple_braces() {
320 assert_eq!(sanitize_json(r#"{"a": {"b": 1"#), r#"{"a": {"b": 1}}"#);
321 }
322
323 #[test]
324 fn test_missing_multiple_brackets() {
325 assert_eq!(sanitize_json(r#"[[1, 2], [3"#), r#"[[1, 2], [3]]"#);
326 }
327
328 #[test]
329 fn test_missing_mixed_delimiters() {
330 assert_eq!(sanitize_json(r#"{"items": [1, 2"#), r#"{"items": [1, 2]}"#);
331 }
332
333 #[test]
334 fn test_brace_in_string_ignored() {
335 // Braces inside strings should NOT be counted
336 assert_eq!(sanitize_json(r#"{"msg": "{"}"#), r#"{"msg": "{"}"#);
337 }
338
339 #[test]
340 fn test_mismatched_closer_object_over_array() {
341 // '}' arrives while '[' is still open: close the array first.
342 assert_eq!(sanitize_json(r#"{"a": [1}"#), r#"{"a": [1]}"#);
343 }
344
345 #[test]
346 fn test_mismatched_closer_array_over_object() {
347 // ']' arrives while '{' is still open: close the object first.
348 assert_eq!(sanitize_json(r#"[{"a":1]"#), r#"[{"a":1}]"#);
349 }
350
351 #[test]
352 fn test_stray_extra_closing_brace() {
353 // Second '}' has no matching opener: dropped.
354 assert_eq!(sanitize_json(r#"{"a":1}}"#), r#"{"a":1}"#);
355 }
356
357 #[test]
358 fn test_stray_extra_closing_bracket() {
359 assert_eq!(sanitize_json(r#"[1, 2]]"#), r#"[1, 2]"#);
360 }
361
362 #[test]
363 fn test_mismatched_closer_deep_nesting() {
364 // ']' closes both inner objects before matching the array.
365 assert_eq!(sanitize_json(r#"[{"a": {"b": 1]"#), r#"[{"a": {"b": 1}}]"#);
366 }
367
368 #[test]
369 fn test_mismatched_outputs_are_valid_json() {
370 for input in [r#"{"a": [1}"#, r#"{"a":1}}"#, r#"[{"a":1]"#, r#"[1, 2]]"#] {
371 let fixed = sanitize_json(input);
372 assert!(
373 serde_json::from_str::<serde_json::Value>(&fixed).is_ok(),
374 "sanitize_json({:?}) produced invalid JSON: {:?}",
375 input,
376 fixed
377 );
378 }
379 }
380
381 #[test]
382 fn test_no_missing_delimiters() {
383 assert_eq!(sanitize_json(r#"{"a": 1}"#), r#"{"a": 1}"#);
384 assert_eq!(sanitize_json(r#"[1, 2]"#), r#"[1, 2]"#);
385 }
386
387 // =========================================================================
388 // Combined Tests
389 // =========================================================================
390
391 #[test]
392 fn test_trailing_comma_and_missing_brace() {
393 assert_eq!(sanitize_json(r#"{"a": 1,"#), r#"{"a": 1}"#);
394 }
395
396 #[test]
397 fn test_trailing_comma_and_missing_bracket() {
398 assert_eq!(sanitize_json(r#"[1, 2,"#), r#"[1, 2]"#);
399 }
400
401 #[test]
402 fn test_complex_llm_output() {
403 let input = r#"{
404 "type": "AddDerive",
405 "target": "User",
406 "derives": ["Debug", "Clone",],
407 "#;
408 // Note: closing brace is appended directly (no formatting/indentation)
409 let expected = r#"{
410 "type": "AddDerive",
411 "target": "User",
412 "derives": ["Debug", "Clone"]}"#;
413 assert_eq!(sanitize_json(input), expected);
414 }
415
416 // =========================================================================
417 // Edge Cases
418 // =========================================================================
419
420 #[test]
421 fn test_empty_input() {
422 assert_eq!(sanitize_json(""), "");
423 assert_eq!(sanitize_json(" "), "");
424 }
425
426 #[test]
427 fn test_whitespace_only() {
428 assert_eq!(sanitize_json(" \n\t "), "");
429 }
430
431 #[test]
432 fn test_simple_values() {
433 assert_eq!(sanitize_json("null"), "null");
434 assert_eq!(sanitize_json("true"), "true");
435 assert_eq!(sanitize_json("123"), "123");
436 assert_eq!(sanitize_json(r#""string""#), r#""string""#);
437 }
438
439 #[test]
440 fn test_escaped_quote_in_string() {
441 assert_eq!(
442 sanitize_json(r#"{"msg": "say \"hello\""}"#),
443 r#"{"msg": "say \"hello\""}"#
444 );
445 }
446
447 #[test]
448 fn test_escaped_backslash_in_string() {
449 assert_eq!(
450 sanitize_json(r#"{"path": "C:\\Users\\test"}"#),
451 r#"{"path": "C:\\Users\\test"}"#
452 );
453 }
454
455 #[test]
456 fn test_unclosed_string() {
457 // Unclosed string should be closed
458 assert_eq!(sanitize_json(r#"{"a": "test"#), r#"{"a": "test"}"#);
459 }
460
461 #[test]
462 fn test_deeply_nested() {
463 assert_eq!(
464 sanitize_json(r#"{"a": {"b": {"c": [1, 2,],"#),
465 r#"{"a": {"b": {"c": [1, 2]}}}"#
466 );
467 }
468
469 // =========================================================================
470 // Real-world LLM Output Examples
471 // =========================================================================
472
473 #[test]
474 fn test_llm_truncated_response() {
475 let input = r#"{"type": "RenameIdent", "from": "old_name", "to": "new_na"#;
476 let fixed = sanitize_json(input);
477 assert_eq!(
478 fixed,
479 r#"{"type": "RenameIdent", "from": "old_name", "to": "new_na"}"#
480 );
481 }
482
483 #[test]
484 fn test_llm_array_with_trailing_comma() {
485 let input = r#"{"intents": [
486 {"type": "AddDerive", "target": "User",},
487 {"type": "AddDerive", "target": "Post",},
488 ]}"#;
489 let fixed = sanitize_json(input);
490 assert!(fixed.contains(r#""target": "User"}"#));
491 assert!(fixed.contains(r#""target": "Post"}"#));
492 assert!(!fixed.contains(",}"));
493 assert!(!fixed.contains(",]"));
494 }
495}