| 1 | //! REPL fence-extraction utilities. |
| 2 | //! |
| 3 | //! The agent's main loop scans assistant text for ` ```repl ` fenced blocks |
| 4 | //! and feeds them to a [`crate::repl::runtime::PythonRuntime`]. Capturing |
| 5 | //! `FINAL(...)` and routing sub-LLM RPCs are handled inside the runtime via |
| 6 | //! a stdin/stdout protocol — no scraping required here. |
| 7 | |
| 8 | const REPL_FENCE: &str = "```repl"; |
| 9 | |
| 10 | /// Byte offset of the first opening `` ```repl `` fence at or after `from`. |
| 11 | /// |
| 12 | /// A fence must open its own line (up to three spaces of indent, as in |
| 13 | /// Markdown) and carry no other info string. Prose that mentions the fence |
| 14 | /// mid-line, or a `` ```repl-output `` block, is not code to execute. |
| 15 | fn next_repl_fence(text: &str, from: usize) -> Option<usize> { |
| 16 | let mut line_start = if from == 0 || text.as_bytes().get(from - 1) == Some(&b'\n') { |
| 17 | from |
| 18 | } else { |
| 19 | from + text[from..].find('\n')? + 1 |
| 20 | }; |
| 21 | loop { |
| 22 | let line_end = text[line_start..] |
| 23 | .find('\n') |
| 24 | .map_or(text.len(), |offset| line_start + offset); |
| 25 | let line = &text[line_start..line_end]; |
| 26 | let indent = line.len() - line.trim_start_matches(' ').len(); |
| 27 | if indent <= 3 |
| 28 | && line[indent..] |
| 29 | .strip_prefix(REPL_FENCE) |
| 30 | .is_some_and(|info| info.trim().is_empty()) |
| 31 | { |
| 32 | return Some(line_start + indent); |
| 33 | } |
| 34 | if line_end >= text.len() { |
| 35 | return None; |
| 36 | } |
| 37 | line_start = line_end + 1; |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | /// Check if a string contains a `` ```repl `` fence that opens its own line. |
| 42 | pub fn has_repl_block(text: &str) -> bool { |
| 43 | next_repl_fence(text, 0).is_some() |
| 44 | } |
| 45 | |
| 46 | /// Extract every line-anchored `` ```repl `` block from `text` with byte offsets. |
| 47 | pub fn extract_repl_blocks(text: &str) -> Vec<ReplBlock> { |
| 48 | let mut blocks = Vec::new(); |
| 49 | let mut search_from = 0; |
| 50 | |
| 51 | while let Some(start) = next_repl_fence(text, search_from) { |
| 52 | let after_fence = &text[start..]; |
| 53 | let code_start = after_fence.find('\n').unwrap_or(after_fence.len()); |
| 54 | let code_region = &after_fence[code_start..]; |
| 55 | let Some(end_offset) = code_region.find("\n```") else { |
| 56 | break; |
| 57 | }; |
| 58 | blocks.push(ReplBlock { |
| 59 | code: code_region[..end_offset].to_string(), |
| 60 | start_offset: start, |
| 61 | end_offset: start + code_start + end_offset + 3, |
| 62 | }); |
| 63 | search_from = start + code_start + end_offset + 4; |
| 64 | } |
| 65 | |
| 66 | blocks |
| 67 | } |
| 68 | |
| 69 | /// A `` ```repl `` code block with byte-offset position info. |
| 70 | #[derive(Debug, Clone)] |
| 71 | pub struct ReplBlock { |
| 72 | pub code: String, |
| 73 | pub start_offset: usize, |
| 74 | pub end_offset: usize, |
| 75 | } |
| 76 | |
| 77 | #[cfg(test)] |
| 78 | mod tests { |
| 79 | use super::*; |
| 80 | |
| 81 | #[test] |
| 82 | fn has_repl_block_detects_fence() { |
| 83 | assert!(has_repl_block("some text\n```repl\ncode\n``` more")); |
| 84 | assert!(has_repl_block(" ```repl \r\ncode\n```")); |
| 85 | assert!(!has_repl_block("no repl here ```python\ncode\n```")); |
| 86 | assert!(!has_repl_block("just text")); |
| 87 | } |
| 88 | |
| 89 | #[test] |
| 90 | fn extract_repl_blocks_single() { |
| 91 | let text = "before\n```repl\nprint('hello')\n```\nafter"; |
| 92 | let blocks = extract_repl_blocks(text); |
| 93 | assert_eq!(blocks.len(), 1); |
| 94 | assert_eq!(blocks[0].code.trim(), "print('hello')"); |
| 95 | } |
| 96 | |
| 97 | #[test] |
| 98 | fn extract_repl_blocks_multiple() { |
| 99 | let text = "```repl\ncode1\n```\nmid\n```repl\ncode2\n```\nend"; |
| 100 | let blocks = extract_repl_blocks(text); |
| 101 | assert_eq!(blocks.len(), 2); |
| 102 | assert_eq!(blocks[0].code.trim(), "code1"); |
| 103 | assert_eq!(blocks[1].code.trim(), "code2"); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn extract_repl_blocks_empty_when_none() { |
| 108 | let blocks = extract_repl_blocks("no blocks here"); |
| 109 | assert!(blocks.is_empty()); |
| 110 | } |
| 111 | |
| 112 | #[test] |
| 113 | fn mid_line_mention_is_not_a_fence() { |
| 114 | let text = "a ```repl mention\n```\nx\n```"; |
| 115 | assert!(!has_repl_block(text)); |
| 116 | assert!(extract_repl_blocks(text).is_empty()); |
| 117 | assert!(!has_repl_block("```repl-output\nx\n```")); |
| 118 | assert!(!has_repl_block(" ```repl\nindented code block\n```")); |
| 119 | } |
| 120 | |
| 121 | #[test] |
| 122 | fn mid_line_mention_does_not_hide_a_later_fence() { |
| 123 | let text = "see ```repl here\n```repl\nprint(1)\n```"; |
| 124 | let blocks = extract_repl_blocks(text); |
| 125 | assert_eq!(blocks.len(), 1); |
| 126 | assert_eq!(blocks[0].code.trim(), "print(1)"); |
| 127 | assert_eq!(&text[blocks[0].start_offset..][..7], "```repl"); |
| 128 | } |
| 129 | } |
| 130 |