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