Skip to main content

fuzzy_parser/
extract.rs

1//! Extraction of JSON payloads from raw LLM output
2//!
3//! LLMs rarely return a bare JSON document: the payload is typically
4//! wrapped in a Markdown code fence, surrounded by explanatory prose,
5//! or both. This module locates the JSON payload(s) inside such output
6//! so they can be fed to [`sanitize_json`](crate::sanitize_json) and the
7//! repair functions.
8//!
9//! # Pipeline position
10//!
11//! Extraction is stage 0 of the repair pipeline:
12//!
13//! ```text
14//! raw LLM output --extract_json--> JSON-ish text --sanitize_json--> valid JSON
15//!                                                --repair_*-------> typo-fixed JSON
16//! ```
17//!
18//! # Design
19//!
20//! Extraction is intentionally lenient: the returned slice does not have
21//! to be valid JSON (it may contain trailing commas or be truncated) —
22//! that is what the sanitize stage is for. Balanced-delimiter tracking is
23//! string-aware, so braces inside string literals do not confuse it.
24
25/// Strip a Markdown code fence wrapper from the input.
26///
27/// If the (trimmed) input is a single fenced block — ` ``` ` or
28/// ` ```json ` etc. — the inner content is returned. A missing closing
29/// fence (truncated output) is tolerated. Inputs that are not a single
30/// fenced block are returned unchanged.
31///
32/// # Example
33///
34/// ```
35/// use fuzzy_parser::strip_code_fences;
36///
37/// let output = "```json\n{\"a\": 1}\n```";
38/// assert_eq!(strip_code_fences(output), "{\"a\": 1}");
39/// ```
40pub fn strip_code_fences(input: &str) -> &str {
41    let trimmed = input.trim();
42    let Some(after_open) = trimmed.strip_prefix("```") else {
43        return input;
44    };
45    // Skip the info string (e.g. "json") up to the end of the opening line
46    let Some(newline) = after_open.find('\n') else {
47        return input; // single-line fence marker; nothing to unwrap
48    };
49    let body = after_open[newline + 1..].trim_end();
50    // Drop the closing fence if present (tolerate truncation if absent)
51    let body = body.strip_suffix("```").unwrap_or(body);
52    body.trim()
53}
54
55/// Extract the first JSON payload from raw LLM output.
56///
57/// Handles Markdown code fences and surrounding prose. Among the balanced
58/// `{...}` / `[...]` blocks found, the first one that parses as JSON
59/// (directly, or after [`sanitize_json`](crate::sanitize_json)) is
60/// preferred; otherwise the first block is returned as-is (it may be
61/// truncated output, which the sanitize stage can close).
62///
63/// Returns `None` when the input contains no `{` or `[` at all.
64///
65/// # Example
66///
67/// ````
68/// use fuzzy_parser::extract_json;
69///
70/// let output = r#"Sure! Here is the result:
71///
72/// ```json
73/// {"type": "AddDerive", "target": "User"}
74/// ```
75///
76/// Let me know if you need anything else."#;
77///
78/// assert_eq!(
79///     extract_json(output),
80///     Some(r#"{"type": "AddDerive", "target": "User"}"#)
81/// );
82/// ````
83pub fn extract_json(input: &str) -> Option<&str> {
84    let blocks = extract_json_blocks(input);
85
86    // Prefer the first block that already parses as JSON
87    for block in &blocks {
88        if serde_json::from_str::<serde_json::Value>(block).is_ok() {
89            return Some(block);
90        }
91    }
92    // Then the first block that parses after sanitization
93    for block in &blocks {
94        let sanitized = crate::sanitize::sanitize_json(block);
95        if serde_json::from_str::<serde_json::Value>(&sanitized).is_ok() {
96            return Some(block);
97        }
98    }
99    // Fall back to the first balanced block (e.g. heavily malformed input)
100    blocks.first().copied()
101}
102
103/// Extract all top-level JSON payloads from raw LLM output.
104///
105/// Scans the input (after unwrapping a surrounding code fence, if any) for
106/// balanced `{...}` / `[...]` blocks. A final unbalanced block (truncated
107/// LLM output) is included as the last element so the sanitize stage can
108/// close it.
109///
110/// # Example
111///
112/// ```
113/// use fuzzy_parser::extract_json_blocks;
114///
115/// let output = r#"First: {"a": 1} and second: {"b": 2}"#;
116/// let blocks = extract_json_blocks(output);
117/// assert_eq!(blocks, vec![r#"{"a": 1}"#, r#"{"b": 2}"#]);
118/// ```
119pub fn extract_json_blocks(input: &str) -> Vec<&str> {
120    let mut blocks = Vec::new();
121
122    // Prefer payloads inside Markdown code fences: this keeps fence
123    // markers out of truncated tails and skips prose braces entirely.
124    for segment in fenced_segments(input) {
125        scan_blocks(segment, &mut blocks);
126    }
127    if !blocks.is_empty() {
128        return blocks;
129    }
130
131    scan_blocks(strip_code_fences(input), &mut blocks);
132    blocks
133}
134
135/// Collect the inner content of every Markdown code fence in the input.
136///
137/// A dangling opening fence (truncated output) contributes the rest of
138/// the input as its segment.
139fn fenced_segments(input: &str) -> Vec<&str> {
140    let mut segments = Vec::new();
141    let mut rest = input;
142
143    while let Some(open) = rest.find("```") {
144        let after_open = &rest[open + 3..];
145        // The fence body starts after the opening line (info string)
146        let Some(newline) = after_open.find('\n') else {
147            break;
148        };
149        let body = &after_open[newline + 1..];
150        match body.find("```") {
151            Some(close) => {
152                segments.push(&body[..close]);
153                rest = &body[close + 3..];
154            }
155            None => {
156                segments.push(body); // truncated: no closing fence
157                break;
158            }
159        }
160    }
161
162    segments
163}
164
165/// Scan a text segment for balanced JSON blocks, appending to `blocks`.
166fn scan_blocks<'a>(s: &'a str, blocks: &mut Vec<&'a str>) {
167    let mut pos = 0;
168
169    while let Some(offset) = s[pos..].find(['{', '[']) {
170        let start = pos + offset;
171        match balanced_end(&s[start..]) {
172            Some(len) => {
173                blocks.push(&s[start..start + len]);
174                pos = start + len;
175            }
176            None => {
177                // Truncated final block: include the tail for the
178                // sanitize stage to close.
179                blocks.push(s[start..].trim_end());
180                break;
181            }
182        }
183    }
184}
185
186/// Find the byte length of the balanced block starting at the first
187/// character of `s` (which must be `{` or `[`).
188///
189/// String-aware: delimiters inside string literals are ignored. Mismatched
190/// closers (`{"a": [1}`) still close the block — the sanitize stage
191/// repairs the mismatch itself.
192fn balanced_end(s: &str) -> Option<usize> {
193    let mut depth = 0usize;
194    let mut in_string = false;
195    let mut escaped = false;
196
197    for (i, c) in s.char_indices() {
198        if in_string {
199            if escaped {
200                escaped = false;
201            } else if c == '\\' {
202                escaped = true;
203            } else if c == '"' {
204                in_string = false;
205            }
206            continue;
207        }
208        match c {
209            '"' => in_string = true,
210            '{' | '[' => depth += 1,
211            '}' | ']' => {
212                depth = depth.saturating_sub(1);
213                if depth == 0 {
214                    return Some(i + c.len_utf8());
215                }
216            }
217            _ => {}
218        }
219    }
220
221    None
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_strip_fence_with_language_tag() {
230        let input = "```json\n{\"a\": 1}\n```";
231        assert_eq!(strip_code_fences(input), "{\"a\": 1}");
232    }
233
234    #[test]
235    fn test_strip_fence_without_language_tag() {
236        let input = "```\n[1, 2]\n```";
237        assert_eq!(strip_code_fences(input), "[1, 2]");
238    }
239
240    #[test]
241    fn test_strip_fence_missing_closing() {
242        // Truncated output: opening fence but no closing fence
243        let input = "```json\n{\"a\": 1";
244        assert_eq!(strip_code_fences(input), "{\"a\": 1");
245    }
246
247    #[test]
248    fn test_strip_fence_not_fenced() {
249        let input = "{\"a\": 1}";
250        assert_eq!(strip_code_fences(input), "{\"a\": 1}");
251    }
252
253    #[test]
254    fn test_extract_from_prose() {
255        let input = "Here is the JSON you asked for: {\"a\": 1} — enjoy!";
256        assert_eq!(extract_json(input), Some("{\"a\": 1}"));
257    }
258
259    #[test]
260    fn test_extract_from_fenced_prose() {
261        let input = "Sure!\n\n```json\n{\"type\": \"AddDerive\"}\n```\n\nAnything else?";
262        assert_eq!(extract_json(input), Some("{\"type\": \"AddDerive\"}"));
263    }
264
265    #[test]
266    fn test_extract_array_payload() {
267        let input = "Result: [1, 2, 3] done";
268        assert_eq!(extract_json(input), Some("[1, 2, 3]"));
269    }
270
271    #[test]
272    fn test_extract_prefers_parseable_block() {
273        // The first brace pair is prose, not JSON; the parseable block wins.
274        let input = "In {this} example the payload is {\"a\": 1}.";
275        assert_eq!(extract_json(input), Some("{\"a\": 1}"));
276    }
277
278    #[test]
279    fn test_extract_prefers_sanitizable_block() {
280        // Not directly parseable, but sanitize can fix the trailing comma.
281        let input = "In {this} example the payload is {\"a\": 1,}.";
282        assert_eq!(extract_json(input), Some("{\"a\": 1,}"));
283    }
284
285    #[test]
286    fn test_extract_truncated_payload() {
287        // Truncated output: unbalanced block is returned for sanitize to close
288        let input = "Here you go: {\"a\": {\"b\": 1";
289        assert_eq!(extract_json(input), Some("{\"a\": {\"b\": 1"));
290    }
291
292    #[test]
293    fn test_extract_none_when_no_json() {
294        assert_eq!(extract_json("no json here"), None);
295    }
296
297    #[test]
298    fn test_extract_string_aware_braces() {
299        // Braces inside string literals must not close the block
300        let input = r#"{"note": "use } carefully", "a": 1}"#;
301        assert_eq!(extract_json(input), Some(input));
302    }
303
304    #[test]
305    fn test_extract_escaped_quote_in_string() {
306        let input = r#"{"note": "say \"hi\" {ok}", "a": 1}"#;
307        assert_eq!(extract_json(input), Some(input));
308    }
309
310    #[test]
311    fn test_extract_multiple_blocks() {
312        let input = r#"First: {"a": 1} and second: {"b": 2}"#;
313        let blocks = extract_json_blocks(input);
314        assert_eq!(blocks, vec![r#"{"a": 1}"#, r#"{"b": 2}"#]);
315    }
316
317    #[test]
318    fn test_extract_blocks_includes_truncated_tail() {
319        let input = r#"{"a": 1} then {"b": 2"#;
320        let blocks = extract_json_blocks(input);
321        assert_eq!(blocks, vec![r#"{"a": 1}"#, r#"{"b": 2"#]);
322    }
323
324    #[test]
325    fn test_full_pipeline_extract_sanitize() {
326        // extract -> sanitize on fenced, trailing-comma, truncated output
327        let raw = "```json\n{\"items\": [1, 2,], \"done\": true\n```";
328        let extracted = extract_json(raw).unwrap();
329        let sanitized = crate::sanitize::sanitize_json(extracted);
330        let value: serde_json::Value = serde_json::from_str(&sanitized).unwrap();
331        assert_eq!(value["items"][1], 2);
332        assert_eq!(value["done"], true);
333    }
334}