rust_bf/
repl.rs

1use std::{env, thread};
2use std::io::{self, IsTerminal, Write};
3use std::sync::{mpsc, Arc};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::time::Duration;
6use reedline::{Signal, DefaultPrompt, DefaultPromptSegment, HistoryItem, Highlighter, StyledText};
7use nu_ansi_term::Style;
8use crate::{cli_util, BrainfuckReader, BrainfuckReaderError, bf_only};
9use crate::cli_util::rat_to_nu;
10use crate::reader::StepControl;
11
12pub fn repl_loop() -> io::Result<()> {
13    // Initialize interactive line editor
14    let mut editor = init_line_editor()?;
15
16    // Track the "current editing buffer" across prompts for `:dump`
17    let mut current_buffer: String = String::new();
18
19    loop {
20        // Prompt and read a multi-line submission via editor
21        let submission = read_submission_interactive(&mut editor)?;
22        if submission.is_none() {
23            // EOF or editor closed. End the session cleanly to avoid hanging when stdin is closed
24            println!();
25            io::stdout().flush()?;
26            return Ok(());
27        }
28
29        let submission = submission.unwrap();
30
31        // Meta-command recognition: line starting with `:`
32        if let Some(meta) = parse_meta_command(&submission) {
33            match handle_meta_command(&mut editor, &meta, &current_buffer)? {
34                MetaAction::Exit => return Ok(()),
35                MetaAction::Continue => {},
36                MetaAction::ResetState => {
37                    // Clear any pending state we keep in the loop; editor buffer will be fresh next prompt
38                    current_buffer = String::new();
39                }
40            }
41            continue; // Do not execute or add to history
42        }
43
44        // Update the current buffer snapshot with what was just submitted
45        current_buffer = submission.clone();
46
47        let trimmed = submission.trim();
48        if trimmed.is_empty() {
49            continue; // Ignore empty submissions
50        }
51
52        let filtered = bf_only(&trimmed);
53        if filtered.is_empty() {
54            continue;
55        }
56
57        // Execute the Brainfuck code buffer
58        execute_bf_buffer(filtered);
59
60        // Test hook: if BF_REPL_ONCE=1, exit after one execution
61        if env::var("BF_REPL_ONCE").ok().as_deref() == Some("1") {
62            return Ok(());
63        }
64    }
65}
66
67fn init_line_editor() -> io::Result<reedline::Reedline> {
68    use reedline::{
69        default_emacs_keybindings, EditCommand, Emacs, KeyCode, KeyModifiers, Reedline, ReedlineEvent,
70    };
71
72    // Start from default emacs-like bindings and adjust:
73    // - Enter -> InsertNewLine (do not submit)
74    // - Ctrl+D -> AcceptLine (submit)
75    // - Ctrl+Z -> AcceptLine (submit, for Windows)
76    let mut keybindings = default_emacs_keybindings();
77    keybindings.add_binding(KeyModifiers::NONE, KeyCode::Enter, ReedlineEvent::Edit(vec![EditCommand::InsertNewline]));
78    keybindings.add_binding(KeyModifiers::CONTROL, KeyCode::Char('d'), ReedlineEvent::Submit);
79    keybindings.add_binding(KeyModifiers::CONTROL, KeyCode::Char('z'), ReedlineEvent::Submit);
80    
81    // Default edit-mode navigation.
82    // Up/down move within the current multiline buffer, not history.
83    keybindings.add_binding(
84        KeyModifiers::NONE,
85        KeyCode::Up,
86        ReedlineEvent::Up
87    );
88    keybindings.add_binding(
89        KeyModifiers::NONE,
90        KeyCode::Down,
91        ReedlineEvent::Down
92    );
93    
94    // Explicit history-mode convenience bindings
95    // Alt+Up/Alt+Down or Ctrl+Up/Ctrl+Down to navigate history items.
96    keybindings.add_binding(KeyModifiers::ALT, KeyCode::Up, ReedlineEvent::PreviousHistory);
97    keybindings.add_binding(KeyModifiers::CONTROL, KeyCode::Up, ReedlineEvent::PreviousHistory);
98    keybindings.add_binding(KeyModifiers::ALT, KeyCode::Down, ReedlineEvent::NextHistory);
99    keybindings.add_binding(KeyModifiers::CONTROL, KeyCode::Down, ReedlineEvent::NextHistory);
100
101    let history = reedline::FileBackedHistory::new(1_000).unwrap();
102
103    let editor = Reedline::create()
104        .with_highlighter(Box::new(BrainfuckHighlighter::new_from_config()))
105        .with_history(Box::new(history))
106        .with_edit_mode(Box::new(Emacs::new(keybindings)));
107
108    Ok(editor)
109}
110
111pub fn read_submission<R: io::BufRead>(stdin: &mut R) -> Option<String> {
112    // Collect all lines until EOF
113    let mut buffer = String::new();
114
115    loop {
116        let mut line = String::new();
117        match stdin.read_line(&mut line) {
118            Ok(0) => {
119                // EOF
120                break;
121            }
122            Ok(_) => {
123                buffer.push_str(&line);
124            }
125            Err(_) => {
126                // Read error, ignore
127                return None;
128            }
129        }
130    }
131
132    if buffer.is_empty() {
133        None
134    } else {
135        Some(buffer)
136    }
137}
138
139fn read_submission_interactive(editor: &mut reedline::Reedline) -> io::Result<Option<String>> {
140    // Minimal prompt
141    let prompt = DefaultPrompt::new(DefaultPromptSegment::Basic("bf".to_string()), DefaultPromptSegment::Empty);
142
143    // Render prompt and read until EOD with Ctrl+D or Ctrl+Z
144    // Enter inserts a newline; history is in-memory and not browsed
145    let res = editor.read_line(&prompt);
146
147    match res {
148        Ok(Signal::Success(buffer)) => {
149            // Add one history item per submitted buffer (program-level)
150            if !buffer.trim().is_empty() && !buffer.trim_start().starts_with(':') {
151                let _ = editor.history_mut().save(HistoryItem::from_command_line(buffer.clone()));
152            }
153            Ok(Some(buffer))
154        }
155        Ok(Signal::CtrlC) => Ok(None), // Global SIGINT, exit immediately
156        Ok(Signal::CtrlD) => Ok(None), // EOF, exit cleanly
157        Err(e) => {
158            // Print concise error and end session
159            eprintln!("repl: editor error: {e}");
160            let _ = io::stderr().flush();
161            Ok(None)
162        }
163    }
164
165}
166
167
168/// Executes a single Brainfuck program contained in `buffer`.
169/// - Program output goes to stdout.
170/// - Errors are printed concisely to stderr.
171/// - A newline is always written to stdout after execution (success or error)
172///   so that the prompt begins at column 0 on the next iteration.
173fn execute_bf_buffer(buffer: String) {
174    // Limits from environment variables
175    let timeout_ms = env::var("BF_TIMEOUT_MS").ok().and_then(|s| s.parse::<usize>().ok()).unwrap_or(2_000);
176    let max_steps = env::var("BF_MAX_STEPS").ok().and_then(|s| s.parse::<usize>().ok());
177
178    // Cooperative cancellation flag
179    let cancel_flag = Arc::new(AtomicBool::new(false));
180    let (tx, rx) = mpsc::channel::<Result<(), BrainfuckReaderError>>();
181    let program = buffer.clone();
182    let cancel_flag_clone = cancel_flag.clone();
183
184    thread::spawn(move || {
185        let mut bf = BrainfuckReader::new(program);
186        let ctrl = StepControl::new(max_steps, cancel_flag_clone);
187        // Run with cooperative cancellation
188        let res = bf.run_with_control(ctrl);
189        let _ = tx.send(res);
190    });
191
192    let timeout = Duration::from_millis(timeout_ms as u64);
193    match rx.recv_timeout(timeout) {
194        Ok(Ok(())) => { } // Success
195        Ok(Err(BrainfuckReaderError::StepLimitExceeded { limit })) => {
196            eprintln!("Execution aborted: step limit exceeded ({limit})");
197            let _ = io::stderr().flush();
198        }
199        Ok(Err(BrainfuckReaderError::Canceled)) => {
200            eprintln!("Execution aborted: wall-clock timeout ({timeout_ms} ms)");
201            let _ = io::stderr().flush();
202        }
203        Ok(Err(other)) => {
204            cli_util::print_reader_error(None, &buffer, &other);
205            let _ = io::stderr().flush();
206        }
207        Err(mpsc::RecvTimeoutError::Timeout) => {
208            // Signal cancel and inform the user
209            cancel_flag.store(true, Ordering::Relaxed);
210            eprintln!("Execution aborted: wall-clock timeout ({} ms)", timeout_ms);
211            let _ = io::stderr().flush();
212        }
213        Err(mpsc::RecvTimeoutError::Disconnected) => {} // Worker ended unexpectedly; nothing to add
214    }
215
216    println!();
217    let _ = io::stdout().flush(); // Ensure output is flushed
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum ReplMode {
222    Bare,
223    Editor,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq)]
227pub enum ModeFlagOverride {
228    None,
229    Bare,
230    Editor,
231}
232
233pub fn select_mode(flag: ModeFlagOverride) -> Result<ReplMode, String> {
234    // Flag override
235    match flag {
236        ModeFlagOverride::Bare => return Ok(ReplMode::Bare),
237        ModeFlagOverride::Editor => {
238            if !io::stdin().is_terminal() {
239                return Err("cannot start editor: stdin is not a TTY (use --bare or BF_REPL_MODE=bare)".to_string());
240            }
241            return Ok(ReplMode::Editor);
242        }
243        ModeFlagOverride::None => {}
244    }
245    
246    // Environment override
247    if let Ok(val) = env::var("BF_REPL_MODE") {
248        let v = val.trim().to_ascii_lowercase();
249        return match v.as_str() {
250            "bare" => Ok(ReplMode::Bare),
251            "editor" => {
252                if !io::stdin().is_terminal() {
253                    return Err("cannot start editor: stdin is not a TTY (use BF_REPL_MODE=bare)".to_string());
254                }
255                Ok(ReplMode::Editor)
256            }
257            _ => Err(format!("invalid BF_REPL_MODE value: {val}, must be 'bare' or 'editor'")),
258        }
259    }
260
261    // Auto-detect
262    if io::stdin().is_terminal() {
263        Ok(ReplMode::Editor)
264    } else {
265        Ok(ReplMode::Bare)
266    }
267}
268
269pub fn execute_bare_once() -> io::Result<()> {
270    let mut locked = io::BufReader::new(io::stdin().lock());
271    let submission = read_submission(&mut locked);
272    if let Some(s) = submission {
273        let trimmed = s.trim();
274        if !trimmed.is_empty() {
275            let filtered = bf_only(trimmed);
276            if !filtered.is_empty() {
277                execute_bf_buffer(filtered);
278            }
279        }
280    }
281    Ok(())
282}
283
284#[derive(Default)]
285struct BrainfuckHighlighter {
286    // Per-char styles for BF commands, and a fallback for non-commands
287    map_plus: Style,
288    map_minus: Style,
289    map_lt: Style,
290    map_gt: Style,
291    map_dot: Style,
292    map_comma: Style,
293    map_lbracket: Style,
294    map_rbracket: Style,
295    map_other: Style,
296}
297
298impl BrainfuckHighlighter {
299    fn new_from_config() -> Self {
300        use crate::config::colors;
301
302        // Character mapping driven by config::Colors
303        let cfg = colors();
304        let mut s = Self::default();
305        s.map_gt = Style::new().fg(rat_to_nu(cfg.editor_op_right)).bold();
306        s.map_lt = Style::new().fg(rat_to_nu(cfg.editor_op_left)).bold();
307        s.map_plus = Style::new().fg(rat_to_nu(cfg.editor_op_inc)).bold();
308        s.map_minus = Style::new().fg(rat_to_nu(cfg.editor_op_dec)).bold();
309        s.map_dot = Style::new().fg(rat_to_nu(cfg.editor_op_output)).bold();
310        s.map_comma = Style::new().fg(rat_to_nu(cfg.editor_op_input)).bold();
311        s.map_lbracket = Style::new().fg(rat_to_nu(cfg.editor_op_bracket)).bold();
312        s.map_rbracket = Style::new().fg(rat_to_nu(cfg.editor_op_bracket)).bold();
313        s.map_other = Style::new().fg(rat_to_nu(cfg.editor_non_bf)).bold();
314        s
315    }
316
317    #[inline]
318    fn style_for(&self, ch: char) -> Style {
319        match ch {
320            '>' => self.map_gt,
321            '<' => self.map_lt,
322            '+' => self.map_plus,
323            '-' => self.map_minus,
324            '.' => self.map_dot,
325            ',' => self.map_comma,
326            '[' => self.map_lbracket,
327            ']' => self.map_rbracket,
328            _ => self.map_other,
329        }
330    }
331}
332
333impl Highlighter for BrainfuckHighlighter {
334    fn highlight(&self, line: &str, _cursor: usize) -> StyledText {
335        let mut out: StyledText = StyledText::new();
336        let mut current_style: Option<Style> = None;
337        let mut buffer = String::new();
338
339        for ch in line.chars() {
340            let style = self.style_for(ch);
341
342            match current_style {
343                None => {
344                    current_style = Some(style);
345                    buffer.push(ch);
346                }
347                Some(s) if s == style => {
348                    buffer.push(ch);
349                }
350                Some(s) => {
351                    out.push((s, std::mem::take(&mut buffer)));
352                    current_style = Some(style);
353                    buffer.push(ch);
354                }
355            }
356        }
357
358        if let Some(s) = current_style {
359            if !buffer.is_empty() {
360                out.push((s, buffer));
361            }
362        }
363        out
364    }
365}
366
367#[derive(Debug, Clone, PartialEq, Eq)]
368enum MetaCommand {
369    /// Exit the REPL immediately with code 0
370    Exit,
371    /// Show help text
372    Help,
373    /// Clear the current editing buffer
374    Reset,
375    /// Print the current editing buffer to stdout or stderr
376    Dump {
377        with_line_numbers: bool,
378        all_to_stderr: bool,
379    },
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383enum MetaAction {
384    Continue,
385    Exit,
386    ResetState,
387}
388
389fn parse_meta_command(input: &str) -> Option<MetaCommand> {
390    let line = input.trim();
391    if !line.starts_with(':') {
392        return None;
393    }
394    let mut parts = line.split_whitespace();
395    let head = parts.next().unwrap_or("");
396    match head {
397        ":exit" | ":quit" => Some(MetaCommand::Exit),
398        ":help" => Some(MetaCommand::Help),
399        ":reset" => Some(MetaCommand::Reset),
400        ":dump" => {
401            let mut with_line_numbers = false;
402            let mut all_to_stderr = false;
403            for arg in parts {
404                match arg {
405                    "--line-numbers" | "-n" => with_line_numbers = true,
406                    "--stderr" | "-e" => all_to_stderr = true,
407                    _ => {}
408                }
409            }
410            Some(MetaCommand::Dump { with_line_numbers, all_to_stderr })
411        }
412        _ => Some(MetaCommand::Help),
413    }
414}
415
416fn handle_meta_command(editor: &mut reedline::Reedline, cmd: &MetaCommand, current_buffer_snapshot: &str) -> io::Result<MetaAction> {
417    use reedline::EditCommand;
418
419    match cmd {
420        MetaCommand::Exit => Ok(MetaAction::Exit),
421        MetaCommand::Help => {
422            print_meta_help_text()?;
423            Ok(MetaAction::Continue)
424        }
425        MetaCommand::Reset => {
426            let _ = editor.run_edit_commands(&[EditCommand::Clear]);
427            eprintln!("buffer reset");
428            let _ = io::stderr().flush();
429            Ok(MetaAction::ResetState)
430        }
431        MetaCommand::Dump { with_line_numbers, all_to_stderr } => {
432            dump_buffer(current_buffer_snapshot, *with_line_numbers, *all_to_stderr)?;
433            Ok(MetaAction::Continue)
434        }
435    }
436}
437
438fn print_meta_help_text() -> io::Result<()> {
439    let mut err = io::stderr();
440    writeln!(err, "Meta commands:")?;
441    writeln!(err, "  :help                Show this help")?;
442    writeln!(err, "  :exit                Exit immediately (code 0)")?;
443    writeln!(err, "  :reset               Clear the current buffer")?;
444    writeln!(err, "  :dump [-n|--stderr]  Print the current buffer (approx: last executed)")?;
445    writeln!(err)?;
446    writeln!(err, "Editing: Enter inserts newline; Ctrl+D (or Ctrl+Z on Windows) submits the buffer")?;
447    writeln!(err, "Streams: program output -> stdout; prompts/meta/errors -> stderr")?;
448    err.flush()?;
449    Ok(())
450}
451
452fn dump_buffer(buf: &str, with_line_numbers: bool, all_to_stderr: bool) -> io::Result<()> {
453    let mut out_stdout = io::stdout();
454    let mut out_stderr = io::stderr();
455
456    let lines: Vec<&str> = if buf.is_empty() { Vec::new() } else { buf.split_inclusive("\n").collect() };
457    let line_count = if lines.is_empty() {
458        if buf.is_empty() { 0 } else { 1 }
459    } else {
460        // split_inclusive keeps newlines and yields at least one element when non-empty
461        let mut c = 0usize;
462        for l in &lines {
463            if l.ends_with('\n') {
464                c += 1;
465            }
466        }
467        if buf.ends_with('\n') { c } else { c + 1 }
468    };
469
470    if all_to_stderr {
471        writeln!(out_stderr, "- dump ({} lines) -", line_count)?;
472        write_dump_lines(&mut out_stderr, buf, with_line_numbers)?;
473        writeln!(out_stderr, "- end dump -")?;
474        out_stderr.flush()?;
475    } else {
476        writeln!(out_stderr, "- dump ({} lines) -", line_count)?;
477        write_dump_lines(&mut out_stdout, buf, with_line_numbers)?;
478        out_stdout.flush()?;
479        writeln!(out_stdout, "")?;
480        writeln!(out_stderr, "- end dump -")?;
481        out_stderr.flush()?;
482    }
483
484    Ok(())
485}
486
487fn write_dump_lines<W: Write>(mut w: W, buf: &str, with_line_numbers: bool) -> io::Result<()> {
488    if !with_line_numbers {
489        write!(w, "{}", buf)?;
490        return Ok(());
491    }
492
493    // Numbered lines
494    if buf.is_empty() {
495        return Ok(());
496    }
497    for (i, line) in buf.split_inclusive('\n').enumerate() {
498        // Keep newline as-is; line numbers on stdout when requested
499        write!(w, "{:>4} | {}", i + 1, line)?;
500        if !line.ends_with('\n') {
501            // If the last line lacks a newline, still print without forcing one
502        }
503    }
504    Ok(())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::io::Cursor;
511
512    #[test]
513    fn read_submission_reads_until_eof_multiple_lines() {
514        let input = b"+++\n>+.\n";
515        let mut cursor = Cursor::new(&input[..]);
516        let got = read_submission(&mut cursor);
517        assert_eq!(got.as_deref(), Some("+++\n>+.\n"));
518    }
519
520    #[test]
521    fn read_submission_empty_returns_none() {
522        let mut cursor = Cursor::new(Vec::<u8>::new());
523        let got = read_submission(&mut cursor);
524        assert!(got.is_none());
525    }
526}