Skip to main content

vtcode_commons/
json_extract.rs

1//! Extraction of balanced JSON blocks embedded in free-form text.
2//!
3//! LLM responses and tool output frequently wrap a JSON payload in prose or
4//! code fences. [`first_json_block`] returns the first balanced `{...}` or
5//! `[...]` block, honoring string literals and rejecting mismatched nesting,
6//! so callers can hand the slice to `serde_json`.
7
8/// Return the first balanced JSON object or array embedded in `text`.
9///
10/// String-aware: braces and brackets inside JSON string literals are ignored.
11/// Nesting is validated with a stack, so mismatched delimiters such as
12/// `{...]` yield `None` instead of a truncated slice.
13#[must_use]
14pub fn first_json_block(text: &str) -> Option<&str> {
15    let (start, opening) = text.char_indices().find(|(_, ch)| matches!(ch, '{' | '['))?;
16    let mut stack = vec![opening];
17    let mut in_string = false;
18    let mut escaped = false;
19
20    for (offset, ch) in text.get(start + opening.len_utf8()..)?.char_indices() {
21        if in_string {
22            if escaped {
23                escaped = false;
24            } else if ch == '\\' {
25                escaped = true;
26            } else if ch == '"' {
27                in_string = false;
28            }
29            continue;
30        }
31
32        match ch {
33            '"' => in_string = true,
34            '{' | '[' => stack.push(ch),
35            '}' => {
36                if stack.pop() != Some('{') {
37                    return None;
38                }
39                if stack.is_empty() {
40                    let end = start + opening.len_utf8() + offset + ch.len_utf8();
41                    return text.get(start..end);
42                }
43            }
44            ']' => {
45                if stack.pop() != Some('[') {
46                    return None;
47                }
48                if stack.is_empty() {
49                    let end = start + opening.len_utf8() + offset + ch.len_utf8();
50                    return text.get(start..end);
51                }
52            }
53            _ => {}
54        }
55    }
56
57    None
58}
59
60#[cfg(test)]
61mod tests {
62    use super::first_json_block;
63
64    #[test]
65    fn finds_object_after_prose_and_fences() {
66        let text = "Here you go:\n```json\n{\"a\": 1}\n```";
67        assert_eq!(first_json_block(text), Some("{\"a\": 1}"));
68    }
69
70    #[test]
71    fn finds_array_block() {
72        assert_eq!(first_json_block("prefix [1, 2, 3] suffix"), Some("[1, 2, 3]"));
73    }
74
75    #[test]
76    fn ignores_braces_inside_strings() {
77        assert_eq!(first_json_block(r#"{"a": "not } here"}"#), Some(r#"{"a": "not } here"}"#));
78    }
79
80    #[test]
81    fn handles_mixed_nesting() {
82        assert_eq!(first_json_block(r#"x {"a": [1, {"b": 2}]} y"#), Some(r#"{"a": [1, {"b": 2}]}"#));
83    }
84
85    #[test]
86    fn rejects_mismatched_delimiters() {
87        assert_eq!(first_json_block("{\"a\": 1]"), None);
88        assert_eq!(first_json_block("[1, 2}"), None);
89    }
90
91    #[test]
92    fn returns_none_for_unterminated_or_missing_json() {
93        assert_eq!(first_json_block("no json here"), None);
94        assert_eq!(first_json_block("{\"a\": 1"), None);
95        assert_eq!(first_json_block(""), None);
96    }
97}