Skip to main content

llm_output_parser/
extract.rs

1//! Shared extraction strategies for LLM output parsing.
2//!
3//! This is the load-bearing module — every parser calls into these functions
4//! for preprocessing, code block extraction, and bracket matching.
5
6use crate::error::ParseError;
7
8/// Full preprocessing pipeline applied to every LLM response.
9///
10/// Strips `<think>` and `<thinking>` blocks, then trims whitespace.
11/// Every parser module calls this as step 1.
12pub fn preprocess(text: &str) -> String {
13    let stripped = strip_think_tags(text);
14    stripped.trim().to_string()
15}
16
17/// Preprocess with configurable think-tag stripping.
18///
19/// When `strip` is false, only trims whitespace.
20pub(crate) fn preprocess_opts(text: &str, strip: bool) -> String {
21    if strip {
22        preprocess(text)
23    } else {
24        text.trim().to_string()
25    }
26}
27
28/// Strip all `<think>...</think>` and `<thinking>...</thinking>` blocks from text.
29///
30/// Handles complete blocks, incomplete blocks (no closing tag),
31/// and multiple sequential blocks.
32///
33/// # Examples
34///
35/// ```
36/// use llm_output_parser::strip_think_tags;
37///
38/// assert_eq!(strip_think_tags("<think>reasoning</think>result"), "result");
39/// assert_eq!(strip_think_tags("<think>no closing tag"), "");
40/// assert_eq!(strip_think_tags("<thinking>also works</thinking>done"), "done");
41/// ```
42pub fn strip_think_tags(text: &str) -> String {
43    let mut result = strip_tag_variant(text, "<think>", "</think>");
44    result = strip_tag_variant(&result, "<thinking>", "</thinking>");
45    result
46}
47
48/// Strip a specific open/close tag pair from text.
49fn strip_tag_variant(text: &str, open: &str, close: &str) -> String {
50    let mut result = text.to_string();
51    while let Some(start) = result.find(open) {
52        if let Some(end_offset) = result[start..].find(close) {
53            let end = start + end_offset + close.len();
54            result = format!("{}{}", &result[..start], &result[end..]);
55        } else {
56            // No closing tag — strip from open tag to end
57            result = result[..start].to_string();
58            break;
59        }
60    }
61    result
62}
63
64/// Extract content from the first matching markdown code block.
65///
66/// Searches for `` ```lang `` and bare `` ``` `` fences.
67/// Returns `(language_hint, content)` where hint is `None` for bare fences.
68///
69/// # Examples
70///
71/// ```
72/// use llm_output_parser::extract::extract_code_block;
73///
74/// let input = "Here:\n```json\n{\"a\": 1}\n```";
75/// let (lang, content) = extract_code_block(input).unwrap();
76/// assert_eq!(lang, Some("json"));
77/// assert_eq!(content, "{\"a\": 1}");
78/// ```
79pub fn extract_code_block(text: &str) -> Option<(Option<&str>, &str)> {
80    let mut search_from = 0;
81    while let Some(fence_start) = text[search_from..].find("```") {
82        let abs_fence = search_from + fence_start;
83        let after_backticks = abs_fence + 3;
84
85        // Determine language hint: everything between ``` and the next newline
86        let line_end = text[after_backticks..].find('\n')?;
87        let lang_str = text[after_backticks..after_backticks + line_end].trim();
88        let lang = if lang_str.is_empty() {
89            None
90        } else {
91            Some(lang_str)
92        };
93
94        let content_start = after_backticks + line_end + 1;
95
96        // Find the closing ```
97        if let Some(close_offset) = text[content_start..].find("```") {
98            let content = text[content_start..content_start + close_offset].trim();
99            return Some((lang, content));
100        }
101
102        search_from = after_backticks;
103    }
104    None
105}
106
107/// Extract content from a code block matching a specific language.
108///
109/// e.g., `extract_code_block_for(text, "json")` looks for `` ```json `` blocks.
110/// Falls back to any code block if no language-specific block is found.
111///
112/// # Examples
113///
114/// ```
115/// use llm_output_parser::extract::extract_code_block_for;
116///
117/// let input = "```json\n[1, 2, 3]\n```";
118/// assert_eq!(extract_code_block_for(input, "json"), Some("[1, 2, 3]"));
119/// ```
120pub fn extract_code_block_for<'a>(text: &'a str, lang: &str) -> Option<&'a str> {
121    // First pass: look for a block with the matching language
122    let mut search_from = 0;
123    while let Some(fence_start) = text[search_from..].find("```") {
124        let abs_fence = search_from + fence_start;
125        let after_backticks = abs_fence + 3;
126
127        if let Some(line_end) = text[after_backticks..].find('\n') {
128            let lang_str = text[after_backticks..after_backticks + line_end].trim();
129            let content_start = after_backticks + line_end + 1;
130
131            if lang_str.eq_ignore_ascii_case(lang) {
132                if let Some(close_offset) = text[content_start..].find("```") {
133                    let content = text[content_start..content_start + close_offset].trim();
134                    return Some(content);
135                }
136            }
137
138            search_from = content_start;
139        } else {
140            break;
141        }
142    }
143    None
144}
145
146/// Find a bracketed substring by matching open/close delimiters.
147///
148/// Handles nesting. Prefers later (more likely to be the actual output)
149/// over earlier occurrences.
150///
151/// - `find_bracketed(text, '[', ']')` — finds JSON arrays
152/// - `find_bracketed(text, '{', '}')` — finds JSON objects
153///
154/// # Examples
155///
156/// ```
157/// use llm_output_parser::extract::find_bracketed;
158///
159/// let input = r#"Result: {"a": [1, 2]}"#;
160/// assert_eq!(find_bracketed(input, '{', '}'), Some(r#"{"a": [1, 2]}"#));
161/// ```
162pub fn find_bracketed(text: &str, open: char, close: char) -> Option<&str> {
163    // Collect all top-level bracketed regions using nesting-aware scanning.
164    // Prefer the last (later) match, which is more likely to be the LLM's answer.
165    let mut best: Option<&str> = None;
166    let mut scan_from = 0;
167
168    while scan_from < text.len() {
169        if let Some(offset) = text[scan_from..].find(open) {
170            let start = scan_from + offset;
171            let mut depth = 0;
172            let mut in_string = false;
173            let mut escape_next = false;
174            let mut found_end = None;
175
176            for (i, ch) in text[start..].char_indices() {
177                if escape_next {
178                    escape_next = false;
179                    continue;
180                }
181                if ch == '\\' && in_string {
182                    escape_next = true;
183                    continue;
184                }
185                if ch == '"' {
186                    in_string = !in_string;
187                    continue;
188                }
189                if in_string {
190                    continue;
191                }
192                if ch == open {
193                    depth += 1;
194                } else if ch == close {
195                    depth -= 1;
196                    if depth == 0 {
197                        found_end = Some(start + i);
198                        break;
199                    }
200                }
201            }
202
203            if let Some(end) = found_end {
204                best = Some(&text[start..=end]);
205                scan_from = end + 1;
206            } else {
207                break;
208            }
209        } else {
210            break;
211        }
212    }
213
214    best
215}
216
217/// Find a bracketed substring with depth limit enforcement.
218///
219/// Returns `Err(ParseError::TooDeep)` if nesting exceeds `max_depth`.
220/// Otherwise behaves identically to [`find_bracketed`].
221pub(crate) fn find_bracketed_limited(
222    text: &str,
223    open: char,
224    close: char,
225    max_depth: usize,
226) -> Result<Option<&str>, ParseError> {
227    let mut best: Option<&str> = None;
228    let mut scan_from = 0;
229
230    while scan_from < text.len() {
231        if let Some(offset) = text[scan_from..].find(open) {
232            let start = scan_from + offset;
233            let mut depth: usize = 0;
234            let mut in_string = false;
235            let mut escape_next = false;
236            let mut found_end = None;
237
238            for (i, ch) in text[start..].char_indices() {
239                if escape_next {
240                    escape_next = false;
241                    continue;
242                }
243                if ch == '\\' && in_string {
244                    escape_next = true;
245                    continue;
246                }
247                if ch == '"' {
248                    in_string = !in_string;
249                    continue;
250                }
251                if in_string {
252                    continue;
253                }
254                if ch == open {
255                    depth += 1;
256                    if depth > max_depth {
257                        return Err(ParseError::TooDeep {
258                            depth,
259                            limit: max_depth,
260                        });
261                    }
262                } else if ch == close {
263                    depth -= 1;
264                    if depth == 0 {
265                        found_end = Some(start + i);
266                        break;
267                    }
268                }
269            }
270
271            if let Some(end) = found_end {
272                best = Some(&text[start..=end]);
273                scan_from = end + 1;
274            } else {
275                break;
276            }
277        } else {
278            break;
279        }
280    }
281
282    Ok(best)
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    // -- strip_think_tags --
290
291    #[test]
292    fn strip_think_tags_complete() {
293        let input = "<think>reasoning</think>result";
294        assert_eq!(strip_think_tags(input), "result");
295    }
296
297    #[test]
298    fn strip_think_tags_incomplete() {
299        let input = "<think>reasoning without close";
300        assert_eq!(strip_think_tags(input), "");
301    }
302
303    #[test]
304    fn strip_think_tags_multiple() {
305        let input = "<think>first</think>middle<think>second</think>end";
306        assert_eq!(strip_think_tags(input), "middleend");
307    }
308
309    #[test]
310    fn strip_thinking_tags_complete() {
311        let input = "<thinking>reasoning</thinking>result";
312        assert_eq!(strip_think_tags(input), "result");
313    }
314
315    #[test]
316    fn strip_thinking_tags_incomplete() {
317        let input = "<thinking>reasoning without close";
318        assert_eq!(strip_think_tags(input), "");
319    }
320
321    #[test]
322    fn strip_think_tags_no_tags() {
323        let input = "just plain text";
324        assert_eq!(strip_think_tags(input), "just plain text");
325    }
326
327    #[test]
328    fn strip_mixed_think_and_thinking() {
329        let input = "<think>a</think>mid<thinking>b</thinking>end";
330        assert_eq!(strip_think_tags(input), "midend");
331    }
332
333    // -- preprocess --
334
335    #[test]
336    fn preprocess_strips_and_trims() {
337        let input = "  <think>stuff</think>  hello world  ";
338        assert_eq!(preprocess(input), "hello world");
339    }
340
341    // -- extract_code_block --
342
343    #[test]
344    fn extract_json_code_block() {
345        let input = "Here:\n```json\n{\"a\": 1}\n```";
346        let (lang, content) = extract_code_block(input).unwrap();
347        assert_eq!(lang, Some("json"));
348        assert_eq!(content, "{\"a\": 1}");
349    }
350
351    #[test]
352    fn extract_bare_code_block() {
353        let input = "Here:\n```\n{\"a\": 1}\n```";
354        let (lang, content) = extract_code_block(input).unwrap();
355        assert_eq!(lang, None);
356        assert_eq!(content, "{\"a\": 1}");
357    }
358
359    #[test]
360    fn extract_code_block_no_fence() {
361        let input = "no code blocks here";
362        assert!(extract_code_block(input).is_none());
363    }
364
365    // -- extract_code_block_for --
366
367    #[test]
368    fn extract_code_block_for_json() {
369        let input = "```json\n[1, 2, 3]\n```";
370        assert_eq!(extract_code_block_for(input, "json"), Some("[1, 2, 3]"));
371    }
372
373    #[test]
374    fn extract_code_block_for_wrong_lang() {
375        let input = "```yaml\nname: test\n```";
376        assert_eq!(extract_code_block_for(input, "json"), None);
377    }
378
379    // -- find_bracketed --
380
381    #[test]
382    fn find_bracketed_json_object() {
383        let input = r#"Result: {"a": [1, 2]}"#;
384        assert_eq!(find_bracketed(input, '{', '}'), Some(r#"{"a": [1, 2]}"#));
385    }
386
387    #[test]
388    fn find_bracketed_json_array() {
389        let input = r#"Here: ["a", "b"] done"#;
390        assert_eq!(find_bracketed(input, '[', ']'), Some(r#"["a", "b"]"#));
391    }
392
393    #[test]
394    fn find_bracketed_nested() {
395        let input = r#"{"outer": {"inner": [1]}}"#;
396        assert_eq!(
397            find_bracketed(input, '{', '}'),
398            Some(r#"{"outer": {"inner": [1]}}"#)
399        );
400    }
401
402    #[test]
403    fn find_bracketed_prefers_later() {
404        // Two separate bracketed regions — prefer the later one
405        let input = r#"[1, 2] and then ["a", "b"]"#;
406        let result = find_bracketed(input, '[', ']');
407        assert_eq!(result, Some(r#"["a", "b"]"#));
408    }
409
410    #[test]
411    fn find_bracketed_no_match() {
412        let input = "no brackets here";
413        assert!(find_bracketed(input, '{', '}').is_none());
414    }
415
416    #[test]
417    fn find_bracketed_with_string_containing_brackets() {
418        let input = r#"{"text": "hello [world]"}"#;
419        assert_eq!(
420            find_bracketed(input, '{', '}'),
421            Some(r#"{"text": "hello [world]"}"#)
422        );
423    }
424
425    // -- find_bracketed_limited --
426
427    #[test]
428    fn find_bracketed_limited_ok() {
429        let input = r#"{"a": {"b": 1}}"#;
430        let result = find_bracketed_limited(input, '{', '}', 10).unwrap();
431        assert_eq!(result, Some(r#"{"a": {"b": 1}}"#));
432    }
433
434    #[test]
435    fn find_bracketed_limited_too_deep() {
436        // Depth 3 nesting with limit 2
437        let input = r#"{"a": {"b": {"c": 1}}}"#;
438        let result = find_bracketed_limited(input, '{', '}', 2);
439        assert!(matches!(
440            result,
441            Err(ParseError::TooDeep { depth: 3, limit: 2 })
442        ));
443    }
444
445    #[test]
446    fn find_bracketed_limited_exact_boundary() {
447        // Depth exactly at limit should succeed
448        let input = r#"{"a": {"b": 1}}"#;
449        let result = find_bracketed_limited(input, '{', '}', 2).unwrap();
450        assert_eq!(result, Some(r#"{"a": {"b": 1}}"#));
451    }
452}