Skip to main content

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}