use crate::ui::highlight::{TokenKind, tokenize};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget, Wrap},
};
const CONTINUATION_PROMPT: &str = ".... ";
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub input: String,
pub output: Option<String>,
pub is_error: bool,
}
impl HistoryEntry {
pub fn new(input: impl Into<String>) -> Self {
Self {
input: input.into(),
output: None,
is_error: false,
}
}
pub fn with_output(mut self, output: impl Into<String>) -> Self {
self.output = Some(output.into());
self
}
pub fn with_error(mut self, error: impl Into<String>) -> Self {
self.output = Some(error.into());
self.is_error = true;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ReplState {
pub history: Vec<HistoryEntry>,
pub input: String,
pub cursor: usize,
pub scroll: u16,
history_index: Option<usize>,
saved_input: String,
}
impl ReplState {
pub fn new() -> Self {
Self::default()
}
pub fn add_entry(&mut self, entry: HistoryEntry) {
self.history.push(entry);
}
pub fn clear_input(&mut self) {
self.input.clear();
self.cursor = 0;
self.history_index = None;
self.saved_input.clear();
}
pub fn insert_char(&mut self, ch: char) {
self.input.insert(self.cursor, ch);
self.cursor += 1;
}
pub fn backspace(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.input.remove(self.cursor);
}
}
pub fn delete(&mut self) {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
pub fn cursor_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn cursor_right(&mut self) {
if self.cursor < self.input.len() {
self.cursor += 1;
}
}
pub fn cursor_home(&mut self) {
self.cursor = 0;
}
pub fn cursor_end(&mut self) {
self.cursor = self.input.len();
}
pub fn current_input(&self) -> &str {
&self.input
}
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_index {
None => {
self.saved_input = self.input.clone();
let last_idx = self.history.len() - 1;
self.history_index = Some(last_idx);
self.input = self.history[last_idx].input.clone();
}
Some(idx) if idx > 0 => {
self.history_index = Some(idx - 1);
self.input = self.history[idx - 1].input.clone();
}
Some(_) => {
}
}
self.cursor = self.input.len();
}
pub fn history_down(&mut self) {
match self.history_index {
Some(idx) if idx + 1 < self.history.len() => {
self.history_index = Some(idx + 1);
self.input = self.history[idx + 1].input.clone();
}
Some(_) => {
self.history_index = None;
self.input = std::mem::take(&mut self.saved_input);
}
None => {
}
}
self.cursor = self.input.len();
}
}
pub struct ReplPane<'a> {
state: &'a ReplState,
focused: bool,
prompt: &'a str,
}
impl<'a> ReplPane<'a> {
pub fn new(state: &'a ReplState) -> Self {
Self {
state,
focused: true,
prompt: "seq> ",
}
}
pub fn focused(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
pub fn prompt(mut self, prompt: &'a str) -> Self {
self.prompt = prompt;
self
}
fn highlight_code(&self, code: &str) -> Line<'a> {
let tokens = tokenize(code);
let spans: Vec<Span> = tokens
.into_iter()
.map(|token| {
let style = match token.kind {
TokenKind::Keyword => Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
TokenKind::Builtin => Style::default().fg(Color::Cyan),
TokenKind::DefMarker | TokenKind::DefEnd => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
TokenKind::Integer | TokenKind::Float => Style::default().fg(Color::Blue),
TokenKind::Boolean => Style::default().fg(Color::Magenta),
TokenKind::String => Style::default().fg(Color::Green),
TokenKind::Comment => Style::default().fg(Color::DarkGray),
TokenKind::TypeName => Style::default().fg(Color::Green),
TokenKind::StackEffect => Style::default().fg(Color::DarkGray),
TokenKind::Quotation => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
TokenKind::Include => Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
TokenKind::ModulePath => Style::default().fg(Color::Cyan),
TokenKind::Identifier => Style::default().fg(Color::White),
TokenKind::Whitespace => Style::default(),
TokenKind::Unknown => Style::default().fg(Color::Red),
};
Span::styled(token.text, style)
})
.collect();
Line::from(spans)
}
fn build_lines(&self) -> Vec<Line<'a>> {
let mut lines = Vec::new();
for entry in &self.state.history {
for (i, input_line) in entry.input.split('\n').enumerate() {
let prompt = if i == 0 {
self.prompt
} else {
CONTINUATION_PROMPT
};
let mut spans = vec![Span::styled(
prompt.to_string(),
Style::default().fg(Color::Green),
)];
spans.extend(self.highlight_code(input_line).spans);
lines.push(Line::from(spans));
}
if let Some(output) = &entry.output {
let style = if entry.is_error {
Style::default().fg(Color::Red)
} else {
Style::default().fg(Color::White)
};
for line in output.lines() {
lines.push(Line::from(Span::styled(format!(" {}", line), style)));
}
}
}
let input_lines: Vec<&str> = self.state.input.split('\n').collect();
let (cursor_line, cursor_col) = if self.focused {
let mut line_idx = 0;
let mut col = self.state.cursor;
let mut pos = 0;
for (i, line_text) in input_lines.iter().enumerate() {
let line_end = pos + line_text.len();
if self.state.cursor <= line_end {
line_idx = i;
col = self.state.cursor - pos;
break;
}
pos = line_end + 1; line_idx = i + 1; }
line_idx = line_idx.min(input_lines.len().saturating_sub(1));
let col = col.min(input_lines.get(line_idx).map_or(0, |l| l.len()));
(line_idx, col)
} else {
(0, 0)
};
for (i, line_text) in input_lines.iter().enumerate() {
let prompt = if i == 0 {
self.prompt
} else {
CONTINUATION_PROMPT
};
let mut spans = vec![Span::styled(
prompt.to_string(),
Style::default().fg(Color::Green),
)];
if self.focused && i == cursor_line {
let col = cursor_col.min(line_text.len());
let (before, after) = line_text.split_at(col);
if !before.is_empty() {
spans.extend(self.highlight_code(before).spans);
}
let cursor_char = if after.is_empty() {
" "
} else {
&after[..after.chars().next().map_or(0, |c| c.len_utf8())]
};
spans.push(Span::styled(
cursor_char.to_string(),
Style::default().bg(Color::White).fg(Color::Black),
));
if !after.is_empty() && after.len() > cursor_char.len() {
spans.extend(self.highlight_code(&after[cursor_char.len()..]).spans);
}
} else {
spans.extend(self.highlight_code(line_text).spans);
}
lines.push(Line::from(spans));
}
lines
}
}
impl Widget for &ReplPane<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let lines = self.build_lines();
let width = area.width.max(1) as usize;
let wrapped_height: u16 = lines
.iter()
.map(|line| {
let line_width: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
line_width.max(1).div_ceil(width).min(u16::MAX as usize) as u16
})
.fold(0u16, |acc, h| acc.saturating_add(h));
let visible_height = area.height;
let scroll = wrapped_height.saturating_sub(visible_height);
let paragraph = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
paragraph.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_history_entry() {
let entry = HistoryEntry::new("5 dup").with_output("5 5");
assert_eq!(entry.input, "5 dup");
assert_eq!(entry.output.as_deref(), Some("5 5"));
assert!(!entry.is_error);
let error = HistoryEntry::new("bad").with_error("unknown word");
assert!(error.is_error);
}
#[test]
fn test_repl_state_input() {
let mut state = ReplState::new();
state.insert_char('h');
state.insert_char('i');
assert_eq!(state.input, "hi");
assert_eq!(state.cursor, 2);
state.backspace();
assert_eq!(state.input, "h");
assert_eq!(state.cursor, 1);
state.cursor_left();
state.insert_char('x');
assert_eq!(state.input, "xh");
}
#[test]
fn test_repl_state_cursor_movement() {
let mut state = ReplState::new();
state.input = "hello".to_string();
state.cursor = 2;
state.cursor_left();
assert_eq!(state.cursor, 1);
state.cursor_home();
assert_eq!(state.cursor, 0);
state.cursor_end();
assert_eq!(state.cursor, 5);
}
#[test]
fn test_repl_pane_render() {
let mut state = ReplState::new();
state.add_entry(HistoryEntry::new("42 dup").with_output("42 42"));
state.input = "swap".to_string();
let pane = ReplPane::new(&state);
let area = Rect::new(0, 0, 40, 10);
let mut buf = Buffer::empty(area);
(&pane).render(area, &mut buf);
}
#[test]
fn test_highlight_code() {
let state = ReplState::new();
let pane = ReplPane::new(&state);
let line = pane.highlight_code("42 dup add");
assert!(!line.spans.is_empty());
}
#[test]
fn test_multiline_input_rendering() {
let mut state = ReplState::new();
state.input = "foo\nbar\nbaz".to_string();
state.cursor = 4;
let pane = ReplPane::new(&state).focused(true);
let lines = pane.build_lines();
assert_eq!(lines.len(), 3);
let first_line_text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(first_line_text.starts_with("seq> "));
assert!(first_line_text.contains("foo"));
let second_line_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(second_line_text.starts_with(".... "));
}
#[test]
fn test_cursor_position_trailing_newline() {
let mut state = ReplState::new();
state.input = "foo\n".to_string();
state.cursor = 4;
let pane = ReplPane::new(&state).focused(true);
let lines = pane.build_lines();
assert_eq!(lines.len(), 2); }
#[test]
fn test_cursor_position_empty_lines() {
let mut state = ReplState::new();
state.input = "foo\n\nbar".to_string();
state.cursor = 4;
let pane = ReplPane::new(&state).focused(true);
let lines = pane.build_lines();
assert_eq!(lines.len(), 3);
}
#[test]
fn test_multiline_history_entry() {
let mut state = ReplState::new();
state.add_entry(HistoryEntry::new("line1\nline2").with_output("result"));
let pane = ReplPane::new(&state);
let lines = pane.build_lines();
assert!(lines.len() >= 3);
}
}