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}