Skip to main content

lc_shared/
json_repair.rs

1// lc-shared/src/json_repair.rs
2//! Tolerant JSON parsing for LLM outputs.
3//!
4//! LLMs frequently produce malformed JSON: trailing commas, unescaped quotes,
5//! incomplete brackets, or markdown-wrapped code blocks. Historically this
6//! logic lived in `lc-core`, but `ToolCall::parse_arguments` (in this crate)
7//! parses the same kind of LLM-generated JSON and cannot depend on `lc-core`.
8//! So the repair pipeline lives here so that every parser of LLM JSON — in
9//! `lc-shared` and `lc-core` alike — converges on a single tolerant parser.
10
11use serde::de::DeserializeOwned;
12
13/// Error types for tolerant JSON parsing.
14#[derive(Debug, thiserror::Error)]
15#[non_exhaustive]
16pub enum JsonRepairError {
17    /// The raw text could not be repaired into valid JSON.
18    #[error("JSON repair failed: {0}")]
19    RepairFailed(String),
20
21    /// The repaired JSON could not be deserialized into the target type.
22    #[error("Deserialization failed: {details}")]
23    DeserializationFailed {
24        /// Details of the deserialization failure.
25        details: String,
26    },
27}
28
29/// Parses LLM JSON output with automatic repair of common errors.
30///
31/// Applies these repairs in order:
32/// 1. Strip markdown code fences (```json ... ```)
33/// 2. Find the outermost JSON bracket pair
34/// 3. Fix unescaped inner quotes within string values (heuristic)
35/// 4. Remove trailing commas before `}` or `]`
36/// 5. Truncate to the last matching `}` or `]` if there's trailing garbage
37///
38/// Returns the deserialized value or an error if repair fails.
39pub fn parse_tolerant_json<T: DeserializeOwned>(raw: &str) -> Result<T, JsonRepairError> {
40    let repaired = repair_json(raw)?;
41    serde_json::from_str::<T>(&repaired).map_err(|e| JsonRepairError::DeserializationFailed {
42        details: format!("{} (repaired JSON: {})", e, truncate(&repaired, 200)),
43    })
44}
45
46/// Repairs common LLM JSON mistakes and extracts the JSON portion.
47///
48/// Returns the repaired JSON string, or an error if no JSON content is found.
49pub fn repair_json(raw: &str) -> Result<String, JsonRepairError> {
50    let trimmed = raw.trim();
51
52    // Step 1: Strip markdown code fences
53    let stripped = strip_code_fences(trimmed);
54
55    // Step 2: Find outermost JSON bracket pair
56    let extracted = extract_bracket_pair(&stripped);
57
58    // Step 3: Fix unescaped inner quotes inside string values
59    let quotes_fixed = fix_unescaped_quotes(&extracted);
60
61    // Step 4: Remove trailing commas before } or ]
62    let no_trailing = remove_trailing_commas(&quotes_fixed);
63
64    // Step 5: Try to truncate trailing garbage
65    let truncated = truncate_to_matching_bracket(&no_trailing);
66
67    // Verify the result is at least syntactically plausible
68    if truncated.is_empty() {
69        return Err(JsonRepairError::RepairFailed(
70            "no JSON content found".to_string(),
71        ));
72    }
73
74    Ok(truncated)
75}
76
77/// Strips markdown code fences from the text.
78pub fn strip_code_fences(text: &str) -> String {
79    let trimmed = text.trim();
80    if let Some(rest) = trimmed.strip_prefix("```json") {
81        if let Some(end) = rest.find("```") {
82            return rest[..end].trim().to_string();
83        }
84        // No closing fence — strip prefix only
85        return rest.trim().to_string();
86    }
87    if let Some(rest) = trimmed.strip_prefix("```") {
88        if let Some(end) = rest.find("```") {
89            return rest[..end].trim().to_string();
90        }
91        return rest.trim().to_string();
92    }
93    trimmed.to_string()
94}
95
96/// Finds the outermost `[...]` or `{...}` bracket pair.
97pub fn extract_bracket_pair(text: &str) -> String {
98    let bytes = text.as_bytes();
99    let start_idx = text.find(['[', '{']);
100
101    if let Some(start) = start_idx {
102        let open = bytes[start];
103        let close = if open == b'[' { b']' } else { b'}' };
104
105        let mut depth = 0i32;
106        let mut in_string = false;
107        let mut escape_next = false;
108
109        for i in start..bytes.len() {
110            let ch = bytes[i];
111            if escape_next {
112                escape_next = false;
113                continue;
114            }
115            if ch == b'\\' && in_string {
116                escape_next = true;
117                continue;
118            }
119            if ch == b'"' {
120                in_string = !in_string;
121                continue;
122            }
123            if in_string {
124                continue;
125            }
126            if ch == open {
127                depth += 1;
128            } else if ch == close {
129                depth -= 1;
130                if depth == 0 {
131                    return text[start..=i].to_string();
132                }
133            }
134        }
135    }
136
137    text.to_string()
138}
139
140/// Escapes `"` characters that appear inside string values but are not the
141/// string's closing quote.
142///
143/// The heuristic: while inside a string, a `"` is treated as the closing
144/// quote only if the next non-whitespace character is a JSON structural
145/// character (`:`, `,`, `]`, `}`) or end of input — that is, a quote that
146/// closes a key or a value. Any other `"` (e.g. `He said "hi"`) is an inner
147/// quote and is escaped. This is a best-effort repair for the common LLM
148/// failure of emitting unescaped quotes inside string values.
149pub fn fix_unescaped_quotes(json: &str) -> String {
150    let chars: Vec<char> = json.chars().collect();
151    let n = chars.len();
152    let mut result = String::with_capacity(json.len());
153    let mut in_string = false;
154    let mut escape_next = false;
155
156    for i in 0..n {
157        let c = chars[i];
158
159        if in_string {
160            if escape_next {
161                result.push(c);
162                escape_next = false;
163                continue;
164            }
165            if c == '\\' {
166                result.push(c);
167                escape_next = true;
168                continue;
169            }
170            if c == '"' {
171                // Look ahead: is this the closing quote of a key or value?
172                let mut j = i + 1;
173                while j < n && chars[j].is_whitespace() {
174                    j += 1;
175                }
176                let is_closing = j >= n || matches!(chars[j], ':' | ',' | ']' | '}');
177                if is_closing {
178                    in_string = false;
179                    result.push(c);
180                } else {
181                    // Inner quote — escape it
182                    result.push('\\');
183                    result.push(c);
184                }
185                continue;
186            }
187            result.push(c);
188        } else if c == '"' {
189            in_string = true;
190            result.push(c);
191        } else {
192            result.push(c);
193        }
194    }
195
196    result
197}
198
199/// Removes trailing commas before closing brackets.
200///
201/// Handles patterns like: `{"a": 1, "b": 2,}` → `{"a": 1, "b": 2}`.
202pub fn remove_trailing_commas(json: &str) -> String {
203    let mut result = String::with_capacity(json.len());
204    let chars: Vec<char> = json.chars().collect();
205    let len = chars.len();
206
207    for i in 0..len {
208        let ch = chars[i];
209        // Check if this comma is followed by ] or } (possibly with whitespace)
210        if ch == ',' {
211            let mut j = i + 1;
212            while j < len && chars[j].is_whitespace() {
213                j += 1;
214            }
215            if j < len && (chars[j] == ']' || chars[j] == '}') {
216                // Skip this trailing comma
217                continue;
218            }
219        }
220        result.push(ch);
221    }
222
223    result
224}
225
226/// Truncates to the last matching closing bracket if there's trailing garbage.
227pub fn truncate_to_matching_bracket(json: &str) -> String {
228    // If the JSON already ends with ] or }, it's likely complete.
229    let trimmed = json.trim_end();
230    if trimmed.ends_with(']') || trimmed.ends_with('}') {
231        return trimmed.to_string();
232    }
233
234    // Find the last closing bracket
235    if let Some(last_close) = trimmed.rfind([']', '}']) {
236        return trimmed[..=last_close].to_string();
237    }
238
239    json.to_string()
240}
241
242/// Truncates a string for display purposes.
243fn truncate(s: &str, max_len: usize) -> String {
244    if s.len() <= max_len {
245        s.to_string()
246    } else {
247        let end = s
248            .char_indices()
249            .take(max_len)
250            .last()
251            .map(|(i, _)| i)
252            .unwrap_or(0);
253        format!("{}...", &s[..end])
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use serde::Deserialize;
261
262    #[derive(Debug, Deserialize, PartialEq)]
263    struct TestStruct {
264        name: String,
265        value: i32,
266    }
267
268    #[test]
269    fn test_parse_valid_json() {
270        let raw = r#"{"name": "test", "value": 42}"#;
271        let result: TestStruct = parse_tolerant_json(raw).unwrap();
272        assert_eq!(result.name, "test");
273        assert_eq!(result.value, 42);
274    }
275
276    #[test]
277    fn test_parse_json_with_code_fence() {
278        let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
279        let result: TestStruct = parse_tolerant_json(raw).unwrap();
280        assert_eq!(result.name, "test");
281    }
282
283    #[test]
284    fn test_parse_json_with_trailing_comma() {
285        let raw = r#"{"name": "test", "value": 42,}"#;
286        let result: TestStruct = parse_tolerant_json(raw).unwrap();
287        assert_eq!(result.name, "test");
288        assert_eq!(result.value, 42);
289    }
290
291    #[test]
292    fn test_parse_json_with_surrounding_text() {
293        let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
294        let result: TestStruct = parse_tolerant_json(raw).unwrap();
295        assert_eq!(result.name, "test");
296    }
297
298    #[test]
299    fn test_parse_json_with_trailing_garbage() {
300        let raw = r#"{"name": "test", "value": 42} and some extra text"#;
301        let result: TestStruct = parse_tolerant_json(raw).unwrap();
302        assert_eq!(result.name, "test");
303    }
304
305    #[test]
306    fn test_parse_json_with_unescaped_inner_quotes() {
307        // LLM output with unescaped quotes inside a string value
308        let raw = r#"{"name": "He said "hi" and left", "value": 1}"#;
309        let result: TestStruct = parse_tolerant_json(raw).unwrap();
310        assert_eq!(result.name, "He said \"hi\" and left");
311        assert_eq!(result.value, 1);
312    }
313
314    #[test]
315    fn test_parse_json_array_with_trailing_comma() {
316        let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
317        let result: Vec<TestStruct> = parse_tolerant_json(raw).unwrap();
318        assert_eq!(result.len(), 2);
319    }
320
321    #[test]
322    fn test_parse_empty_text_fails() {
323        let result: Result<TestStruct, _> = parse_tolerant_json("");
324        assert!(result.is_err());
325    }
326
327    #[test]
328    fn test_parse_no_json_content_fails() {
329        let result: Result<TestStruct, _> = parse_tolerant_json("just some plain text");
330        assert!(result.is_err());
331    }
332
333    #[test]
334    fn test_fix_unescaped_quotes_inner_only() {
335        let input = r#"{"a": "He said "hi"", "b": 2}"#;
336        assert_eq!(
337            fix_unescaped_quotes(input),
338            r#"{"a": "He said \"hi\"", "b": 2}"#
339        );
340    }
341
342    #[test]
343    fn test_fix_unescaped_quotes_leaves_closing_quotes() {
344        let input = r#"{"key": "value", "n": 1}"#;
345        assert_eq!(fix_unescaped_quotes(input), input);
346    }
347
348    #[test]
349    fn test_fix_unescaped_quotes_empty_string() {
350        let input = r#"{"a": ""}"#;
351        assert_eq!(fix_unescaped_quotes(input), input);
352    }
353
354    #[test]
355    fn test_strip_code_fences_json() {
356        let input = "```json\n{\"key\": \"val\"}\n```";
357        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
358    }
359
360    #[test]
361    fn test_strip_code_fences_plain() {
362        let input = "```\n{\"key\": \"val\"}\n```";
363        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
364    }
365
366    #[test]
367    fn test_strip_code_fences_no_fence() {
368        let input = "{\"key\": \"val\"}";
369        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
370    }
371
372    #[test]
373    fn test_remove_trailing_commas_object() {
374        let input = r#"{"a": 1, "b": 2,}"#;
375        assert_eq!(remove_trailing_commas(input), r#"{"a": 1, "b": 2}"#);
376    }
377
378    #[test]
379    fn test_remove_trailing_commas_array() {
380        let input = r#"[1, 2, 3,]"#;
381        assert_eq!(remove_trailing_commas(input), r#"[1, 2, 3]"#);
382    }
383
384    #[test]
385    fn test_remove_trailing_commas_nested() {
386        let input = r#"{"arr": [1, 2,], "val": 3,}"#;
387        assert_eq!(
388            remove_trailing_commas(input),
389            r#"{"arr": [1, 2], "val": 3}"#
390        );
391    }
392
393    #[test]
394    fn test_extract_bracket_pair_array() {
395        let input = "prefix [1, 2, 3] suffix";
396        assert_eq!(extract_bracket_pair(input), "[1, 2, 3]");
397    }
398
399    #[test]
400    fn test_extract_bracket_pair_object() {
401        let input = r#"text {"a": 1} more"#;
402        assert_eq!(extract_bracket_pair(input), r#"{"a": 1}"#);
403    }
404
405    #[test]
406    fn test_parse_json_no_closing_fence() {
407        let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
408        let result: TestStruct = parse_tolerant_json(raw).unwrap();
409        assert_eq!(result.name, "test");
410    }
411
412    #[test]
413    fn test_error_display() {
414        let err = JsonRepairError::RepairFailed("no json".to_string());
415        assert!(err.to_string().contains("no json"));
416    }
417}