Skip to main content

rl_lang/repl/
logic_loop.rs

1//! The main REPL event loop - input handling, rendering, and evaluation dispatch.
2//!
3//! # State
4//!
5//! | Variable        | Purpose                                                  |
6//! |-----------------|----------------------------------------------------------|
7//! | `input_buf`     | The current line being typed                             |
8//! | `accumulated`   | Multiline buffer - grows until [`is_complete`] is true   |
9//! | `cursor_pos`    | Cursor position in chars (not bytes)                     |
10//! | `history`       | Submitted input history, navigated with `↑`/`↓`          |
11//! | `history_idx`   | Current position in history (`None` = live input)        |
12//! | `attached`      | Files evaluated into the env via `:attach`               |
13//! | `scroll_offset` | Output scroll position (`0` = bottom, higher = older)    |
14//!
15//! # Multiline input
16//!
17//! Lines are accumulated into `accumulated` until [`is_complete`] returns `true`
18//! and the last line does not end with `{` (a bare block opener waits for body).
19//! The prompt switches from `>>` to `..` while accumulating.
20//! `Esc` cancels and clears the accumulator.
21//!
22//! # Evaluation flow
23//!
24//! ```text
25//! Enter key
26//!   |
27//!   |- starts with ':'  ──► handle_command
28//!   |
29//!   |- code input
30//!        |
31//!        |- not complete yet --> keep accumulating
32//!        |
33//!        |- complete --> eval_input --> output buffer
34//! ```
35use super::{
36    command_handler::handle_command, depth_checker::is_complete, input_eval::eval_input,
37    lines_types::OutputLine, output_render::render_output, syntax_highlighting::highlight,
38    utils::char_to_byte,
39};
40use crossterm::event::{self, Event, KeyCode, KeyModifiers};
41use ratatui::{
42    DefaultTerminal,
43    layout::{Constraint, Direction, Layout},
44    style::{Color, Modifier, Style},
45    text::{Line, Span},
46    widgets::{Block, Borders, Paragraph, Wrap},
47};
48use std::path::PathBuf;
49
50use crate::interpreter::evaluator::Evaluator;
51
52/// Runs the REPL event loop until the user exits with `Ctrl+C` or `:exit`.
53///
54/// Initializes a fresh [`Evaluator`] with the stdlib loaded, then enters a
55/// draw-then-poll loop: renders the current state, blocks on the next key
56/// event, and dispatches it. Returns an [`io::Result`] so terminal errors
57/// propagate cleanly to [`start_repl`].
58pub fn run_repl(terminal: &mut DefaultTerminal) -> std::io::Result<()> {
59    let mut evaluator = Evaluator::default().with_stdlib();
60    let mut output: Vec<OutputLine> = vec![
61        OutputLine::Info(format!(
62            "rl-lang v{} - type :help for commands",
63            env!("CARGO_PKG_VERSION")
64        )),
65        OutputLine::Separator,
66    ];
67
68    let mut input_buf = String::new(); // current line being typed
69    let mut accumulated = String::new(); // multiline accumulator
70    let mut cursor_pos: usize = 0; // cursor position in chars
71    let mut history: Vec<String> = Vec::new();
72    let mut history_idx: Option<usize> = None;
73    let mut attached: Vec<PathBuf> = Vec::new();
74    let mut scroll_offset: usize = 0;
75
76    loop {
77        // draw
78        let is_continuation = !accumulated.is_empty();
79        let prompt = if is_continuation { ".. " } else { ">> " };
80
81        terminal.draw(|frame| {
82            let area = frame.area();
83            let chunks = Layout::default()
84                .direction(Direction::Vertical)
85                .constraints([Constraint::Min(3), Constraint::Length(3)])
86                .split(area);
87
88            // output area
89            let out_lines = render_output(&output);
90            let total = out_lines.len();
91            let visible = chunks[0].height.saturating_sub(2) as usize;
92            let max_scroll = total.saturating_sub(visible);
93            // scroll_offset=0 means bottom and larger values scroll up
94            let scroll = max_scroll.saturating_sub(scroll_offset) as u16;
95
96            let output_widget = Paragraph::new(out_lines)
97                .block(
98                    Block::default()
99                        .borders(Borders::ALL)
100                        .border_style(Style::default().fg(Color::DarkGray))
101                        .title(Span::styled(
102                            " rl ",
103                            Style::default()
104                                .fg(Color::Cyan)
105                                .add_modifier(Modifier::BOLD),
106                        )),
107                )
108                .wrap(Wrap { trim: false })
109                .scroll((scroll, 0));
110            frame.render_widget(output_widget, chunks[0]);
111
112            // input area
113            let prompt_color = if is_continuation {
114                Color::Yellow
115            } else {
116                Color::Cyan
117            };
118            let mut input_spans = vec![Span::styled(
119                prompt,
120                Style::default()
121                    .fg(prompt_color)
122                    .add_modifier(Modifier::BOLD),
123            )];
124
125            if input_buf.is_empty() {
126                // blinking style cursor on empty input
127                input_spans.push(Span::styled("│", Style::default().fg(Color::DarkGray)));
128            } else {
129                // split at char boundary safe for any unicode
130                let before: String = input_buf.chars().take(cursor_pos).collect();
131                let mut after_chars = input_buf.chars().skip(cursor_pos);
132
133                let mut hl = highlight(&before);
134                input_spans.append(&mut hl);
135
136                match after_chars.next() {
137                    None => {
138                        // cursor past end of text
139                        input_spans.push(Span::styled(
140                            " ",
141                            Style::default().bg(Color::Cyan).fg(Color::Black),
142                        ));
143                    }
144                    Some(c) => {
145                        let cursor_str = c.to_string();
146                        let rest: String = after_chars.collect();
147                        input_spans.push(Span::styled(
148                            cursor_str,
149                            Style::default().bg(Color::Cyan).fg(Color::Black),
150                        ));
151                        let mut hl2 = highlight(&rest);
152                        input_spans.append(&mut hl2);
153                    }
154                }
155            }
156
157            let input_widget = Paragraph::new(Line::from(input_spans)).block(
158                Block::default()
159                    .borders(Borders::ALL)
160                    .border_style(Style::default().fg(Color::DarkGray)),
161            );
162            frame.render_widget(input_widget, chunks[1]);
163        })?;
164
165        // events
166        if let Event::Key(key) = event::read()? {
167            match (key.modifiers, key.code) {
168                // exit
169                (KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
170
171                // submit
172                (_, KeyCode::Enter) => {
173                    let line = input_buf.clone();
174                    input_buf.clear();
175                    cursor_pos = 0;
176                    history_idx = None;
177                    scroll_offset = 0;
178
179                    if line.trim().is_empty() && accumulated.is_empty() {
180                        continue;
181                    }
182
183                    // commands only at top level (not inside a multiline block)
184                    if accumulated.is_empty() && line.trim().starts_with(':') {
185                        if line.trim() == ":exit" {
186                            break;
187                        }
188                        output.push(OutputLine::Input(line.clone()));
189                        handle_command(line.trim(), &mut output, &mut evaluator, &mut attached);
190                        if !line.trim().is_empty() {
191                            history.push(line);
192                        }
193                        continue;
194                    }
195
196                    // record which prompt was shown for this line before we push to accumulated
197                    let is_first_line = accumulated.is_empty();
198
199                    if !accumulated.is_empty() {
200                        accumulated.push('\n');
201                    }
202                    accumulated.push_str(&line);
203
204                    // first line: render_output adds ">> " prefix automatically via Input variant
205                    // continuation lines: prepend ".. " inside the string so it shows correctly
206                    if is_first_line {
207                        output.push(OutputLine::Input(line.clone()));
208                    } else {
209                        output.push(OutputLine::Input(format!(".. {}", line)));
210                    }
211
212                    if is_complete(&accumulated)
213                        && (accumulated.lines().count() > 1
214                            || !accumulated.trim_end().ends_with('{'))
215                    {
216                        let full = accumulated.clone();
217                        accumulated.clear();
218                        if !full.trim().is_empty() {
219                            history.push(full.trim().to_string());
220                        }
221
222                        let success = eval_input(full.trim(), &mut evaluator, &mut output);
223                        if success {
224                            output.push(OutputLine::ValidInput(full.trim().to_string()));
225                        }
226                    }
227                }
228
229                // backspace
230                (_, KeyCode::Backspace) if cursor_pos > 0 => {
231                    let byte_pos = char_to_byte(&input_buf, cursor_pos - 1);
232                    input_buf.remove(byte_pos);
233                    cursor_pos -= 1;
234                }
235
236                // delete
237                (_, KeyCode::Delete) if cursor_pos < input_buf.chars().count() => {
238                    let byte_pos = char_to_byte(&input_buf, cursor_pos);
239                    input_buf.remove(byte_pos);
240                }
241
242                // word jump (ctrl+left / ctrl+right)
243                (KeyModifiers::CONTROL, KeyCode::Left) => {
244                    let chars: Vec<char> = input_buf.chars().collect();
245                    let mut i = cursor_pos;
246                    while i > 0 && chars[i - 1] == ' ' {
247                        i -= 1;
248                    }
249                    while i > 0 && chars[i - 1] != ' ' {
250                        i -= 1;
251                    }
252                    cursor_pos = i;
253                }
254                (KeyModifiers::CONTROL, KeyCode::Right) => {
255                    let chars: Vec<char> = input_buf.chars().collect();
256                    let len = chars.len();
257                    let mut i = cursor_pos;
258                    while i < len && chars[i] != ' ' {
259                        i += 1;
260                    }
261                    while i < len && chars[i] == ' ' {
262                        i += 1;
263                    }
264                    cursor_pos = i;
265                }
266
267                // cursor movement
268                (_, KeyCode::Left) if cursor_pos > 0 => {
269                    cursor_pos = cursor_pos.saturating_sub(1);
270                }
271
272                (_, KeyCode::Right) if cursor_pos < input_buf.chars().count() => {
273                    cursor_pos += 1;
274                }
275
276                (_, KeyCode::Home) => cursor_pos = 0,
277
278                (_, KeyCode::End) => cursor_pos = input_buf.chars().count(),
279
280                // scroll output (shift+up/down)
281                (KeyModifiers::SHIFT, KeyCode::Up) => {
282                    scroll_offset += 1;
283                }
284
285                (KeyModifiers::SHIFT, KeyCode::Down) => {
286                    scroll_offset = scroll_offset.saturating_sub(1);
287                }
288
289                // history up
290                (_, KeyCode::Up) => {
291                    if history.is_empty() {
292                        continue;
293                    }
294                    let new_idx = match history_idx {
295                        None => history.len() - 1,
296                        Some(0) => 0,
297                        Some(i) => i - 1,
298                    };
299                    history_idx = Some(new_idx);
300                    input_buf = history[new_idx].clone();
301                    cursor_pos = input_buf.chars().count();
302                }
303
304                // history down
305                (_, KeyCode::Down) => match history_idx {
306                    None => {}
307                    Some(i) if i + 1 >= history.len() => {
308                        history_idx = None;
309                        input_buf.clear();
310                        cursor_pos = 0;
311                    }
312                    Some(i) => {
313                        history_idx = Some(i + 1);
314                        input_buf = history[i + 1].clone();
315                        cursor_pos = input_buf.chars().count();
316                    }
317                },
318
319                // escape - cancel multiline accumulation
320                (_, KeyCode::Esc) => {
321                    if !accumulated.is_empty() {
322                        accumulated.clear();
323                        output.push(OutputLine::Info("cancelled".into()));
324                    }
325                    input_buf.clear();
326                    cursor_pos = 0;
327                }
328
329                // normal character input
330                (_, KeyCode::Char(c)) => {
331                    let byte_pos = char_to_byte(&input_buf, cursor_pos);
332                    input_buf.insert(byte_pos, c);
333                    cursor_pos += 1;
334                }
335
336                _ => {}
337            }
338        }
339    }
340
341    Ok(())
342}