llm_output_parser/
extract.rs1use crate::error::ParseError;
7
8pub fn preprocess(text: &str) -> String {
13 let stripped = strip_think_tags(text);
14 stripped.trim().to_string()
15}
16
17pub(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
28pub 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
48fn 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 result = result[..start].to_string();
58 break;
59 }
60 }
61 result
62}
63
64pub 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 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 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
107pub fn extract_code_block_for<'a>(text: &'a str, lang: &str) -> Option<&'a str> {
121 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
146pub fn find_bracketed(text: &str, open: char, close: char) -> Option<&str> {
163 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
217pub(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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 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}