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}`.
202///
203/// 0.20.0 K1: tracks string state so a comma inside a string literal (e.g.
204/// `{"a": "x, }"}`) is kept — it is content, not a trailing comma. Mirrors the
205/// in-string/escape state machine used by [`extract_bracket_pair`] /
206/// [`fix_unescaped_quotes`]; without it the old version corrupted string values
207/// whose text ended with a comma followed by `}`/`]`.
208pub fn remove_trailing_commas(json: &str) -> String {
209    let mut result = String::with_capacity(json.len());
210    let chars: Vec<char> = json.chars().collect();
211    let len = chars.len();
212    let mut in_string = false;
213    let mut escape_next = false;
214
215    for i in 0..len {
216        let ch = chars[i];
217        if in_string {
218            result.push(ch);
219            if escape_next {
220                escape_next = false;
221            } else if ch == '\\' {
222                escape_next = true;
223            } else if ch == '"' {
224                in_string = false;
225            }
226            continue;
227        }
228        if ch == '"' {
229            in_string = true;
230            result.push(ch);
231            continue;
232        }
233        // Check if this comma is followed by ] or } (possibly with whitespace)
234        if ch == ',' {
235            let mut j = i + 1;
236            while j < len && chars[j].is_whitespace() {
237                j += 1;
238            }
239            if j < len && (chars[j] == ']' || chars[j] == '}') {
240                // Skip this trailing comma
241                continue;
242            }
243        }
244        result.push(ch);
245    }
246
247    result
248}
249
250/// Truncates to the last matching closing bracket if there's trailing garbage.
251pub fn truncate_to_matching_bracket(json: &str) -> String {
252    // If the JSON already ends with ] or }, it's likely complete.
253    let trimmed = json.trim_end();
254    if trimmed.ends_with(']') || trimmed.ends_with('}') {
255        return trimmed.to_string();
256    }
257
258    // Find the last closing bracket
259    if let Some(last_close) = trimmed.rfind([']', '}']) {
260        return trimmed[..=last_close].to_string();
261    }
262
263    json.to_string()
264}
265
266/// Truncates a string for display purposes.
267fn truncate(s: &str, max_len: usize) -> String {
268    if s.len() <= max_len {
269        s.to_string()
270    } else {
271        let end = s
272            .char_indices()
273            .take(max_len)
274            .last()
275            .map(|(i, _)| i)
276            .unwrap_or(0);
277        format!("{}...", &s[..end])
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use serde::Deserialize;
285
286    #[derive(Debug, Deserialize, PartialEq)]
287    struct TestStruct {
288        name: String,
289        value: i32,
290    }
291
292    #[test]
293    fn test_parse_valid_json() {
294        let raw = r#"{"name": "test", "value": 42}"#;
295        let result: TestStruct = parse_tolerant_json(raw).unwrap();
296        assert_eq!(result.name, "test");
297        assert_eq!(result.value, 42);
298    }
299
300    #[test]
301    fn test_parse_json_with_code_fence() {
302        let raw = "```json\n{\"name\": \"test\", \"value\": 42}\n```";
303        let result: TestStruct = parse_tolerant_json(raw).unwrap();
304        assert_eq!(result.name, "test");
305    }
306
307    #[test]
308    fn test_parse_json_with_trailing_comma() {
309        let raw = r#"{"name": "test", "value": 42,}"#;
310        let result: TestStruct = parse_tolerant_json(raw).unwrap();
311        assert_eq!(result.name, "test");
312        assert_eq!(result.value, 42);
313    }
314
315    #[test]
316    fn test_parse_json_with_surrounding_text() {
317        let raw = "Here is the result: {\"name\": \"test\", \"value\": 42} done.";
318        let result: TestStruct = parse_tolerant_json(raw).unwrap();
319        assert_eq!(result.name, "test");
320    }
321
322    #[test]
323    fn test_parse_json_with_trailing_garbage() {
324        let raw = r#"{"name": "test", "value": 42} and some extra text"#;
325        let result: TestStruct = parse_tolerant_json(raw).unwrap();
326        assert_eq!(result.name, "test");
327    }
328
329    #[test]
330    fn test_parse_json_with_unescaped_inner_quotes() {
331        // LLM output with unescaped quotes inside a string value
332        let raw = r#"{"name": "He said "hi" and left", "value": 1}"#;
333        let result: TestStruct = parse_tolerant_json(raw).unwrap();
334        assert_eq!(result.name, "He said \"hi\" and left");
335        assert_eq!(result.value, 1);
336    }
337
338    #[test]
339    fn test_parse_json_array_with_trailing_comma() {
340        let raw = r#"[{"name": "a", "value": 1}, {"name": "b", "value": 2},]"#;
341        let result: Vec<TestStruct> = parse_tolerant_json(raw).unwrap();
342        assert_eq!(result.len(), 2);
343    }
344
345    #[test]
346    fn test_parse_empty_text_fails() {
347        let result: Result<TestStruct, _> = parse_tolerant_json("");
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn test_parse_no_json_content_fails() {
353        let result: Result<TestStruct, _> = parse_tolerant_json("just some plain text");
354        assert!(result.is_err());
355    }
356
357    #[test]
358    fn test_fix_unescaped_quotes_inner_only() {
359        let input = r#"{"a": "He said "hi"", "b": 2}"#;
360        assert_eq!(
361            fix_unescaped_quotes(input),
362            r#"{"a": "He said \"hi\"", "b": 2}"#
363        );
364    }
365
366    #[test]
367    fn test_fix_unescaped_quotes_leaves_closing_quotes() {
368        let input = r#"{"key": "value", "n": 1}"#;
369        assert_eq!(fix_unescaped_quotes(input), input);
370    }
371
372    #[test]
373    fn test_fix_unescaped_quotes_empty_string() {
374        let input = r#"{"a": ""}"#;
375        assert_eq!(fix_unescaped_quotes(input), input);
376    }
377
378    #[test]
379    fn test_strip_code_fences_json() {
380        let input = "```json\n{\"key\": \"val\"}\n```";
381        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
382    }
383
384    #[test]
385    fn test_strip_code_fences_plain() {
386        let input = "```\n{\"key\": \"val\"}\n```";
387        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
388    }
389
390    #[test]
391    fn test_strip_code_fences_no_fence() {
392        let input = "{\"key\": \"val\"}";
393        assert_eq!(strip_code_fences(input), "{\"key\": \"val\"}");
394    }
395
396    #[test]
397    fn test_remove_trailing_commas_object() {
398        let input = r#"{"a": 1, "b": 2,}"#;
399        assert_eq!(remove_trailing_commas(input), r#"{"a": 1, "b": 2}"#);
400    }
401
402    #[test]
403    fn test_remove_trailing_commas_array() {
404        let input = r#"[1, 2, 3,]"#;
405        assert_eq!(remove_trailing_commas(input), r#"[1, 2, 3]"#);
406    }
407
408    #[test]
409    fn test_remove_trailing_commas_nested() {
410        let input = r#"{"arr": [1, 2,], "val": 3,}"#;
411        assert_eq!(
412            remove_trailing_commas(input),
413            r#"{"arr": [1, 2], "val": 3}"#
414        );
415    }
416
417    #[test]
418    fn test_remove_trailing_commas_preserves_string_literals() {
419        // 0.20.0 K1: commas inside string values are content, not trailing commas.
420        let input = r#"{"a": "text, } more"}"#;
421        assert_eq!(remove_trailing_commas(input), input);
422
423        let input = r#"{"a": "x,]", "b": 1}"#;
424        assert_eq!(remove_trailing_commas(input), input);
425
426        let input = r#"{"a": "he said \"hi, \"", "b": 1,}"#;
427        assert_eq!(
428            remove_trailing_commas(input),
429            r#"{"a": "he said \"hi, \"", "b": 1}"#
430        );
431
432        let input = r#"{"a": "1, 2, 3", "b": [1,]}"#;
433        assert_eq!(
434            remove_trailing_commas(input),
435            r#"{"a": "1, 2, 3", "b": [1]}"#
436        );
437    }
438
439    #[test]
440    fn test_extract_bracket_pair_array() {
441        let input = "prefix [1, 2, 3] suffix";
442        assert_eq!(extract_bracket_pair(input), "[1, 2, 3]");
443    }
444
445    #[test]
446    fn test_extract_bracket_pair_object() {
447        let input = r#"text {"a": 1} more"#;
448        assert_eq!(extract_bracket_pair(input), r#"{"a": 1}"#);
449    }
450
451    #[test]
452    fn test_parse_json_no_closing_fence() {
453        let raw = "```json\n{\"name\": \"test\", \"value\": 42}";
454        let result: TestStruct = parse_tolerant_json(raw).unwrap();
455        assert_eq!(result.name, "test");
456    }
457
458    #[test]
459    fn test_error_display() {
460        let err = JsonRepairError::RepairFailed("no json".to_string());
461        assert!(err.to_string().contains("no json"));
462    }
463}