use crate::completion::CompletionManager;
use crate::engine::{AnalysisResult, analyze, analyze_expression};
use crate::ir::stack_art::{Stack, StackEffect, render_transition};
use crate::keys::convert_key;
use crate::ui::ir_pane::{IrContent, IrPane, IrViewMode};
use crate::ui::layout::{ComputedLayout, LayoutConfig, StatusContent};
use crate::ui::repl_pane::{HistoryEntry, ReplPane, ReplState};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use std::fs;
use std::io::{BufRead, BufReader, Read as _, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use tempfile::NamedTempFile;
const DEFAULT_TIMEOUT_SECS: u64 = 10;
#[allow(dead_code)] enum RunResult {
Success { stdout: String, stderr: String },
Failed {
stdout: String,
stderr: String,
status: ExitStatus,
},
Timeout { timeout_secs: u64 },
Error(String),
}
fn run_with_timeout(path: &Path) -> RunResult {
let timeout_secs = std::env::var("SEQ_REPL_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
let timeout = Duration::from_secs(timeout_secs);
let mut child = match Command::new(path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => return RunResult::Error(format!("Failed to start: {}", e)),
};
let start = Instant::now();
let poll_interval = Duration::from_millis(50);
loop {
match child.try_wait() {
Ok(Some(status)) => {
let stdout = child
.stdout
.take()
.map(|mut s| {
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
buf
})
.unwrap_or_default();
let stderr = child
.stderr
.take()
.map(|mut s| {
let mut buf = String::new();
let _ = s.read_to_string(&mut buf);
buf
})
.unwrap_or_default();
if status.success() {
return RunResult::Success { stdout, stderr };
} else {
return RunResult::Failed {
stdout,
stderr,
status,
};
}
}
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait(); return RunResult::Timeout { timeout_secs };
}
std::thread::sleep(poll_interval);
}
Err(e) => {
return RunResult::Error(format!("Wait error: {}", e));
}
}
}
}
use vim_line::{Action, LineEditor, TextEdit, VimLineEditor};
const REPL_TEMPLATE: &str = r#"# Seq REPL session
# Expressions are auto-printed via stack.dump
# --- includes ---
# --- definitions ---
# --- main ---
: main ( -- )
"#;
const MAIN_CLOSE: &str = " stack.dump\n;\n";
const INCLUDES_MARKER: &str = "# --- includes ---";
const MAIN_MARKER: &str = "# --- main ---";
pub struct App {
pub repl_state: ReplState,
pub ir_content: IrContent,
pub ir_mode: IrViewMode,
pub editor: VimLineEditor,
pub layout_config: LayoutConfig,
pub filename: String,
pub show_ir_pane: bool,
pub should_quit: bool,
pub should_edit: bool,
pub status_message: Option<String>,
pub session_path: PathBuf,
_temp_file: Option<NamedTempFile>,
completions: CompletionManager,
search_mode: bool,
search_pattern: String,
search_matches: Vec<usize>,
search_match_index: usize,
search_original_input: String,
}
const MAX_HISTORY_IN_MEMORY: usize = 1000;
fn floor_char_boundary(s: &str, pos: usize) -> usize {
if pos >= s.len() {
s.len()
} else if s.is_char_boundary(pos) {
pos
} else {
let mut p = pos;
while p > 0 && !s.is_char_boundary(p) {
p -= 1;
}
p
}
}
impl App {
pub fn new() -> Result<Self, String> {
let temp_file = NamedTempFile::with_suffix(".seq")
.map_err(|e| format!("Failed to create temp file: {}", e))?;
let session_path = temp_file.path().to_path_buf();
let initial_content = format!("{}{}", REPL_TEMPLATE, MAIN_CLOSE);
fs::write(&session_path, &initial_content)
.map_err(|e| format!("Failed to write session file: {}", e))?;
let completions = CompletionManager::try_with_lsp(&session_path, &initial_content);
let mut app = Self {
repl_state: ReplState::new(),
ir_content: IrContent::new(),
ir_mode: IrViewMode::default(),
editor: VimLineEditor::new(),
layout_config: LayoutConfig::default(),
filename: "(scratch)".to_string(),
show_ir_pane: false,
should_quit: false,
should_edit: false,
status_message: None,
session_path,
_temp_file: Some(temp_file),
completions,
search_mode: false,
search_pattern: String::new(),
search_matches: Vec::new(),
search_match_index: 0,
search_original_input: String::new(),
};
app.load_history();
Ok(app)
}
pub fn with_file(path: PathBuf) -> Result<Self, String> {
let filename = path.display().to_string();
let content = if !path.exists() {
let c = format!("{}{}", REPL_TEMPLATE, MAIN_CLOSE);
fs::write(&path, &c).map_err(|e| format!("Failed to create session file: {}", e))?;
c
} else {
match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!(
"Warning: Could not read session file '{}': {}",
path.display(),
e
);
eprintln!("Starting with empty session.");
String::new()
}
}
};
let completions = CompletionManager::try_with_lsp(&path, &content);
let mut app = Self {
repl_state: ReplState::new(),
ir_content: IrContent::new(),
ir_mode: IrViewMode::default(),
editor: VimLineEditor::new(),
layout_config: LayoutConfig::default(),
filename,
show_ir_pane: false,
should_quit: false,
should_edit: false,
status_message: None,
session_path: path,
_temp_file: None,
completions,
search_mode: false,
search_pattern: String::new(),
search_matches: Vec::new(),
search_match_index: 0,
search_original_input: String::new(),
};
app.load_history();
Ok(app)
}
fn history_file_path() -> Option<PathBuf> {
home::home_dir().map(|d| d.join(".local/share/seqr_history"))
}
fn load_history(&mut self) {
if let Some(path) = Self::history_file_path()
&& path.exists()
&& let Ok(file) = fs::File::open(&path)
{
let reader = BufReader::new(file);
let lines: Vec<String> = reader
.lines()
.map_while(Result::ok)
.filter(|line| !line.is_empty())
.collect();
let start = lines.len().saturating_sub(MAX_HISTORY_IN_MEMORY);
for line in &lines[start..] {
self.repl_state
.add_entry(HistoryEntry::new(line.clone()).with_output("(previous session)"));
}
}
}
pub fn save_history(&self) {
if let Some(path) = Self::history_file_path() {
if let Some(parent) = path.parent()
&& let Err(e) = fs::create_dir_all(parent)
{
eprintln!("Warning: could not create history directory: {e}");
return;
}
match fs::File::create(&path) {
Ok(mut file) => {
let start = self.repl_state.history.len().saturating_sub(1000);
for entry in &self.repl_state.history[start..] {
if let Err(e) = writeln!(file, "{}", entry.input) {
eprintln!("Warning: could not write history entry: {e}");
return;
}
}
}
Err(e) => {
eprintln!("Warning: could not create history file: {e}");
}
}
}
}
fn is_normal_mode(&self) -> bool {
self.editor.status() == "NORMAL"
}
pub fn handle_key(&mut self, key: KeyEvent) {
self.status_message = None;
if self.completions.is_visible() {
match key.code {
KeyCode::Esc => {
self.completions.hide();
return;
}
KeyCode::Up | KeyCode::Char('k') if self.is_normal_mode() => {
self.completions.up();
return;
}
KeyCode::Down | KeyCode::Char('j') if self.is_normal_mode() => {
self.completions.down();
return;
}
KeyCode::Up => {
self.completions.up();
return;
}
KeyCode::Down => {
self.completions.down();
return;
}
KeyCode::Tab => {
self.completions.down();
return;
}
KeyCode::Enter => {
self.accept_completion();
return;
}
_ => {
self.completions.hide();
}
}
}
if self.search_mode {
match key.code {
KeyCode::Esc => {
self.repl_state.input = self.search_original_input.clone();
self.editor.reset();
self.editor
.set_cursor(self.repl_state.input.len(), &self.repl_state.input);
self.repl_state.cursor = self.editor.cursor();
self.search_mode = false;
self.search_pattern.clear();
self.search_matches.clear();
self.status_message = None;
return;
}
KeyCode::Enter => {
self.search_mode = false;
self.search_pattern.clear();
self.search_matches.clear();
return;
}
KeyCode::Backspace => {
self.search_pattern.pop();
self.update_search_matches();
self.preview_current_match();
self.update_search_status();
return;
}
KeyCode::Tab if key.modifiers.contains(KeyModifiers::SHIFT) => {
if !self.search_matches.is_empty() {
self.search_match_index = if self.search_match_index == 0 {
self.search_matches.len() - 1
} else {
self.search_match_index - 1
};
self.preview_current_match();
self.update_search_status();
}
return;
}
KeyCode::Tab | KeyCode::BackTab => {
if !self.search_matches.is_empty() {
if key.code == KeyCode::BackTab {
self.search_match_index = if self.search_match_index == 0 {
self.search_matches.len() - 1
} else {
self.search_match_index - 1
};
} else {
self.search_match_index =
(self.search_match_index + 1) % self.search_matches.len();
}
self.preview_current_match();
self.update_search_status();
}
return;
}
KeyCode::Char(c) => {
self.search_pattern.push(c);
self.update_search_matches();
self.preview_current_match();
self.update_search_status();
return;
}
_ => return,
}
}
if key.code == KeyCode::Char('/') && self.is_normal_mode() {
self.search_mode = true;
self.search_original_input = self.repl_state.input.clone();
self.search_pattern.clear();
self.search_matches.clear();
self.search_match_index = 0;
self.update_search_status();
return;
}
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') | KeyCode::Char('d') | KeyCode::Char('q') => {
self.should_quit = true;
return;
}
KeyCode::Char('l') => {
return;
}
KeyCode::Char('n') => {
if self.show_ir_pane {
self.ir_mode = self.ir_mode.next();
}
return;
}
_ => {}
}
}
match key.code {
KeyCode::F(1) => {
self.toggle_ir_view(IrViewMode::StackArt);
return;
}
KeyCode::F(2) => {
self.toggle_ir_view(IrViewMode::TypedAst);
return;
}
KeyCode::F(3) => {
self.toggle_ir_view(IrViewMode::LlvmIr);
return;
}
_ => {}
}
if key.code == KeyCode::Tab {
self.request_completions();
return;
}
let is_modified_enter = key.code == KeyCode::Enter
&& (key.modifiers.contains(KeyModifiers::SHIFT)
|| key.modifiers.contains(KeyModifiers::ALT));
let is_newline_char = key.code == KeyCode::Char('\n');
if (is_modified_enter || is_newline_char) && self.editor.status() == "INSERT" {
let cursor = self.editor.cursor();
self.repl_state.input.insert(cursor, '\n');
self.editor.set_cursor(cursor + 1, &self.repl_state.input);
self.repl_state.cursor = self.editor.cursor();
return;
}
if key.code == KeyCode::Enter && self.editor.status() == "INSERT" {
self.execute_input();
return;
}
if self.editor.status() == "NORMAL" {
let input = &self.repl_state.input;
let cursor = floor_char_boundary(input, self.editor.cursor());
match key.code {
KeyCode::Char('k') => {
let on_first_line = !input[..cursor].contains('\n');
if on_first_line {
self.navigate_history_prev();
return;
}
}
KeyCode::Char('j') => {
let on_last_line = !input[cursor..].contains('\n');
if on_last_line {
self.navigate_history_next();
return;
}
}
_ => {}
}
}
let vl_key = convert_key(key);
let result = self.editor.handle_key(vl_key, &self.repl_state.input);
let had_edits = !result.edits.is_empty();
for edit in result.edits.into_iter().rev() {
match edit {
TextEdit::Delete { start, end } => {
self.repl_state.input.replace_range(start..end, "");
}
TextEdit::Insert { at, text } => {
self.repl_state.input.insert_str(at, &text);
}
}
}
self.repl_state.cursor = self.editor.cursor();
if let Some(action) = result.action {
match action {
Action::Submit => {
self.execute_input();
}
Action::HistoryPrev => {
self.navigate_history_prev();
}
Action::HistoryNext => {
self.navigate_history_next();
}
Action::Cancel => {
self.should_quit = true;
}
}
}
if had_edits {
self.update_ir_preview();
}
}
fn execute_input(&mut self) {
let input = self.repl_state.current_input().to_string();
if input.trim().is_empty() {
return;
}
let trimmed = input.trim_start();
if trimmed.starts_with(':') && !trimmed.starts_with(": ") && !trimmed.starts_with(":\t") {
let cmd = input.clone();
self.handle_command(&cmd);
return;
}
if trimmed.starts_with(": ") || trimmed.starts_with(":\t") {
self.try_definition(&input);
return;
}
self.try_expression(&input);
}
fn try_definition(&mut self, def: &str) {
let original = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(e) => {
self.add_error_entry(def, &format!("Error reading file: {}", e));
return;
}
};
if !self.add_definition(def) {
return;
}
let output_path = self.session_path.with_extension("");
match seqc::compile_file(&self.session_path, &output_path, false) {
Ok(_) => {
let _ = fs::remove_file(&output_path);
self.repl_state
.add_entry(HistoryEntry::new(def).with_output("Defined."));
self.repl_state.clear_input();
}
Err(e) => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Warning: Could not rollback session file: {}",
rollback_err
));
}
self.add_error_entry(def, &e.to_string());
}
}
}
fn add_definition(&mut self, def: &str) -> bool {
let content = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(_) => return false,
};
let main_pos = match content.find(MAIN_MARKER) {
Some(p) => p,
None => return false,
};
let mut new_content = String::new();
new_content.push_str(&content[..main_pos]);
new_content.push_str(def);
new_content.push_str("\n\n");
new_content.push_str(&content[main_pos..]);
fs::write(&self.session_path, new_content).is_ok()
}
fn try_expression(&mut self, expr: &str) {
let original = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(e) => {
self.add_error_entry(expr, &format!("Error reading file: {}", e));
return;
}
};
if !self.append_expression(expr) {
self.add_error_entry(expr, "Failed to append expression");
return;
}
let output_path = self.session_path.with_extension("");
match seqc::compile_file(&self.session_path, &output_path, false) {
Ok(_) => {
let result = run_with_timeout(&output_path);
let _ = fs::remove_file(&output_path);
match result {
RunResult::Success { stdout, stderr: _ } => {
self.update_ir_from_session(expr);
let output_text = stdout.trim();
if output_text.is_empty() {
self.repl_state
.add_entry(HistoryEntry::new(expr).with_output("ok"));
} else {
self.repl_state
.add_entry(HistoryEntry::new(expr).with_output(output_text));
}
}
RunResult::Failed {
stdout: _,
stderr,
status,
} => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Warning: Could not rollback session file: {}",
rollback_err
));
}
let err = if stderr.is_empty() {
format!("exit: {:?}", status.code())
} else {
stderr.trim().to_string()
};
self.repl_state
.add_entry(HistoryEntry::new(expr).with_error(&err));
}
RunResult::Timeout { timeout_secs } => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Warning: Could not rollback session file: {}",
rollback_err
));
}
self.repl_state
.add_entry(HistoryEntry::new(expr).with_error(format!(
"Timeout after {}s (SEQ_REPL_TIMEOUT to adjust)",
timeout_secs
)));
}
RunResult::Error(e) => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Warning: Could not rollback session file: {}",
rollback_err
));
}
self.add_error_entry(expr, &format!("Run error: {}", e));
}
}
self.repl_state.clear_input();
}
Err(e) => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Warning: Could not rollback session file: {}",
rollback_err
));
}
self.add_error_entry(expr, &e.to_string());
}
}
}
fn append_expression(&mut self, expr: &str) -> bool {
if expr.trim() == "stack.dump" {
return true; }
let content = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(_) => return false,
};
let dump_pos = match content.find(" stack.dump") {
Some(p) => p,
None => return false,
};
let mut new_content = String::new();
new_content.push_str(&content[..dump_pos]);
new_content.push_str(" ");
new_content.push_str(expr);
new_content.push('\n');
new_content.push_str(&content[dump_pos..]);
fs::write(&self.session_path, new_content).is_ok()
}
fn pop_last_expression(&mut self) -> bool {
let content = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(_) => return false,
};
let main_pos = match content.find(": main") {
Some(p) => p,
None => return false,
};
let main_line_end = match content[main_pos..].find('\n') {
Some(p) => main_pos + p + 1,
None => return false,
};
let dump_pos = match content.find(" stack.dump") {
Some(p) => p,
None => return false,
};
let expr_section = &content[main_line_end..dump_pos];
let lines: Vec<&str> = expr_section.lines().collect();
let mut last_expr_idx = None;
for (i, line) in lines.iter().enumerate().rev() {
if !line.trim().is_empty() {
last_expr_idx = Some(i);
break;
}
}
let last_expr_idx = match last_expr_idx {
Some(i) => i,
None => return false, };
let mut new_content = String::new();
new_content.push_str(&content[..main_line_end]);
for (i, line) in lines.iter().enumerate() {
if i != last_expr_idx {
new_content.push_str(line);
new_content.push('\n');
}
}
new_content.push_str(&content[dump_pos..]);
fs::write(&self.session_path, new_content).is_ok()
}
fn clear_session(&mut self) {
if let Err(e) = fs::write(
&self.session_path,
format!("{}{}", REPL_TEMPLATE, MAIN_CLOSE),
) {
self.status_message = Some(format!("Warning: Could not clear session file: {}", e));
return;
}
self.repl_state = ReplState::new();
self.ir_content = IrContent::new();
}
fn add_include(&mut self, module: &str) -> bool {
let content = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(_) => return false,
};
let include_stmt = format!("include {}", module);
if content.contains(&include_stmt) {
self.status_message = Some(format!("'{}' is already included.", module));
return false;
}
let includes_pos = match content.find(INCLUDES_MARKER) {
Some(p) => p,
None => return false,
};
let marker_end = includes_pos + INCLUDES_MARKER.len();
let after_marker = &content[marker_end..];
let newline_pos = after_marker.find('\n').unwrap_or(0);
let insert_pos = marker_end + newline_pos + 1;
let mut new_content = String::new();
new_content.push_str(&content[..insert_pos]);
new_content.push_str("include ");
new_content.push_str(module);
new_content.push('\n');
new_content.push_str(&content[insert_pos..]);
fs::write(&self.session_path, new_content).is_ok()
}
fn try_include(&mut self, module: &str) {
let original = match fs::read_to_string(&self.session_path) {
Ok(c) => c,
Err(_) => return,
};
if !self.add_include(module) {
return;
}
let output_path = self.session_path.with_extension("");
match seqc::compile_file(&self.session_path, &output_path, false) {
Ok(_) => {
let _ = fs::remove_file(&output_path);
self.status_message = Some(format!("Included '{}'.", module));
}
Err(e) => {
if let Err(rollback_err) = fs::write(&self.session_path, &original) {
self.status_message = Some(format!(
"Include error: {} (also failed to rollback: {})",
e, rollback_err
));
} else {
self.status_message = Some(format!("Include error: {}", e));
}
}
}
self.repl_state.clear_input();
}
fn update_ir_from_session(&mut self, expr: &str) {
if let Ok(source) = fs::read_to_string(&self.session_path) {
let result = analyze(&source);
if result.errors.is_empty() {
self.update_ir_from_result(&result, expr);
}
}
}
fn add_error_entry(&mut self, input: &str, error: &str) {
self.repl_state
.add_entry(HistoryEntry::new(input).with_error(error));
self.repl_state.clear_input();
}
fn handle_command(&mut self, cmd: &str) {
let cmd = cmd.trim();
match cmd {
":q" | ":quit" => {
self.should_quit = true;
}
":version" | ":v" => {
let version = env!("CARGO_PKG_VERSION");
self.repl_state
.add_entry(HistoryEntry::new(cmd).with_output(format!("seqr {version}")));
self.status_message = Some(format!("seqr {}", version));
}
":clear" => {
self.clear_session();
self.repl_state.add_entry(HistoryEntry::new(":clear"));
self.status_message = Some("Session cleared.".to_string());
}
":pop" => {
if self.pop_last_expression() {
self.repl_state.add_entry(HistoryEntry::new(":pop"));
self.show_stack_in_ir_pane();
self.status_message = Some("Popped last expression.".to_string());
} else {
self.status_message = Some("Nothing to pop.".to_string());
}
}
":stack" | ":s" => {
self.compile_and_show_stack(":stack");
}
":show" => {
self.repl_state.add_entry(HistoryEntry::new(":show"));
if let Ok(content) = fs::read_to_string(&self.session_path) {
self.ir_content = IrContent {
stack_art: content.lines().map(String::from).collect(),
typed_ast: vec!["(session file contents)".to_string()],
llvm_ir: vec![],
errors: vec![],
};
self.ir_mode = IrViewMode::StackArt;
}
}
":ir" => {
self.repl_state.add_entry(HistoryEntry::new(":ir"));
self.show_ir_pane = !self.show_ir_pane;
if self.show_ir_pane {
self.status_message =
Some(format!("IR: {} (Ctrl+N to cycle)", self.ir_mode.name()));
} else {
self.status_message = Some("IR pane hidden".to_string());
}
}
":ir stack" => {
self.repl_state.add_entry(HistoryEntry::new(":ir stack"));
self.show_ir_pane = true;
self.ir_mode = IrViewMode::StackArt;
self.status_message = Some("IR: Stack Effects".to_string());
}
":ir ast" => {
self.repl_state.add_entry(HistoryEntry::new(":ir ast"));
self.show_ir_pane = true;
self.ir_mode = IrViewMode::TypedAst;
self.status_message = Some("IR: Typed AST".to_string());
}
":ir llvm" => {
self.repl_state.add_entry(HistoryEntry::new(":ir llvm"));
self.show_ir_pane = true;
self.ir_mode = IrViewMode::LlvmIr;
self.status_message = Some("IR: LLVM IR".to_string());
}
":edit" | ":e" => {
self.repl_state.add_entry(HistoryEntry::new(cmd));
self.should_edit = true;
}
":help" | ":h" => {
self.repl_state.add_entry(HistoryEntry::new(cmd));
self.ir_content = IrContent {
stack_art: vec![
"â•─────────────────────────────────────╮".to_string(),
"│ Seq TUI REPL │".to_string(),
"╰─────────────────────────────────────╯".to_string(),
String::new(),
"COMMANDS".to_string(),
" :q, :quit Exit the REPL".to_string(),
" :version, :v Show version".to_string(),
" :clear Clear session and history".to_string(),
" :pop Remove last expression".to_string(),
" :stack, :s Show current stack".to_string(),
" :show Show session file".to_string(),
" :edit, :e Open in $EDITOR".to_string(),
" :ir Toggle IR pane".to_string(),
" :ir stack Show stack effects".to_string(),
" :ir ast Show typed AST".to_string(),
" :ir llvm Show LLVM IR".to_string(),
" :include <m> Include module".to_string(),
" :help, :h Show this help".to_string(),
String::new(),
"VI MODE".to_string(),
" i, a, A, I Enter insert mode".to_string(),
" Esc Return to normal mode".to_string(),
" h, l Move cursor left/right".to_string(),
" j, k History down/up".to_string(),
" w, b Word forward/backward".to_string(),
" 0, $ Line start/end".to_string(),
" x Delete character".to_string(),
" d Clear line".to_string(),
" / Search history".to_string(),
String::new(),
"KEYS".to_string(),
" F1 Toggle Stack Effects".to_string(),
" F2 Toggle Typed AST".to_string(),
" F3 Toggle LLVM IR".to_string(),
" Tab Show completions".to_string(),
" Ctrl+N Cycle IR views".to_string(),
" Ctrl+D Exit REPL".to_string(),
" Enter Execute expression".to_string(),
" Up/Down History navigation".to_string(),
String::new(),
"SEARCH MODE (after /)".to_string(),
" Type Filter history".to_string(),
" Tab/Shift+Tab Cycle matches".to_string(),
" Enter Accept match".to_string(),
" Esc Cancel search".to_string(),
],
typed_ast: vec![],
llvm_ir: vec![],
errors: vec![],
};
self.ir_mode = IrViewMode::StackArt;
self.show_ir_pane = true;
}
_ if cmd.starts_with(":include ") => {
let module = &cmd[":include ".len()..].trim();
if module.is_empty() {
self.status_message = Some("Usage: :include <module>".to_string());
} else {
self.repl_state.add_entry(HistoryEntry::new(cmd));
self.try_include(module);
return; }
}
_ => {
self.status_message = Some(format!("Unknown command: {}", cmd));
}
}
self.repl_state.clear_input();
}
fn compile_and_show_stack(&mut self, command: &str) {
let output_path = self.session_path.with_extension("");
match seqc::compile_file(&self.session_path, &output_path, false) {
Ok(_) => {
let result = run_with_timeout(&output_path);
let _ = fs::remove_file(&output_path);
match result {
RunResult::Success { stdout, stderr: _ } => {
let output_text = stdout.trim();
if !output_text.is_empty() {
self.repl_state
.add_entry(HistoryEntry::new(command).with_output(output_text));
} else {
self.repl_state
.add_entry(HistoryEntry::new(command).with_output("(empty)"));
}
}
RunResult::Timeout { timeout_secs } => {
self.status_message = Some(format!(
"Timeout after {}s while showing stack",
timeout_secs
));
}
_ => {
}
}
}
Err(e) => {
self.status_message = Some(format!("Compile error: {}", e));
}
}
}
fn show_stack_in_ir_pane(&mut self) {
let output_path = self.session_path.with_extension("");
match seqc::compile_file(&self.session_path, &output_path, false) {
Ok(_) => {
let result = run_with_timeout(&output_path);
let _ = fs::remove_file(&output_path);
match result {
RunResult::Success { stdout, stderr: _ } => {
let output_text = stdout.trim();
let mut lines = vec!["Stack:".to_string()];
if !output_text.is_empty() {
lines.extend(output_text.lines().map(String::from));
} else {
lines.push("(empty)".to_string());
}
self.ir_content = IrContent {
stack_art: lines,
typed_ast: vec![],
llvm_ir: vec![],
errors: vec![],
};
self.ir_mode = IrViewMode::StackArt;
self.show_ir_pane = true;
}
_ => {
}
}
}
Err(_) => {
}
}
}
fn update_search_matches(&mut self) {
self.search_matches.clear();
self.search_match_index = 0;
if self.search_pattern.is_empty() {
return;
}
let pattern = self.search_pattern.to_lowercase();
for (i, entry) in self.repl_state.history.iter().enumerate().rev() {
if entry.input.to_lowercase().contains(&pattern) {
self.search_matches.push(i);
}
}
}
fn preview_current_match(&mut self) {
if self.search_matches.is_empty() {
self.repl_state.input = self.search_original_input.clone();
} else {
let idx = self.search_matches[self.search_match_index];
if let Some(entry) = self.repl_state.history.get(idx) {
self.repl_state.input = entry.input.clone();
}
}
self.editor.reset();
self.editor
.set_cursor(self.repl_state.input.len(), &self.repl_state.input);
self.repl_state.cursor = self.editor.cursor();
}
fn update_search_status(&mut self) {
if self.search_matches.is_empty() {
if self.search_pattern.is_empty() {
self.status_message = Some("/".to_string());
} else {
self.status_message = Some(format!("/{} (no matches)", self.search_pattern));
}
} else {
let match_num = self.search_match_index + 1;
let total = self.search_matches.len();
self.status_message = Some(format!(
"/{} ({}/{})",
self.search_pattern, match_num, total
));
}
}
fn update_ir_preview(&mut self) {
let input = self.repl_state.current_input().to_string();
if input.trim().is_empty() {
self.ir_content = IrContent::new();
return;
}
self.ir_content = IrContent {
stack_art: self.generate_stack_art(&input),
typed_ast: vec![format!("Expression: {}", input)],
llvm_ir: vec!["(compile with Enter to see LLVM IR)".to_string()],
errors: vec![],
};
}
fn navigate_history_prev(&mut self) {
self.repl_state.history_up();
self.editor.reset();
self.editor
.set_cursor(self.repl_state.input.len(), &self.repl_state.input);
self.repl_state.cursor = self.editor.cursor();
}
fn navigate_history_next(&mut self) {
self.repl_state.history_down();
self.editor.reset();
self.editor
.set_cursor(self.repl_state.input.len(), &self.repl_state.input);
self.repl_state.cursor = self.editor.cursor();
}
fn toggle_ir_view(&mut self, mode: IrViewMode) {
if self.show_ir_pane && self.ir_mode == mode {
self.show_ir_pane = false;
self.status_message = Some("IR pane hidden".to_string());
} else {
self.show_ir_pane = true;
self.ir_mode = mode;
self.status_message = Some(format!("IR: {}", mode.name()));
}
}
fn update_ir_from_result(&mut self, _result: &AnalysisResult, input: &str) {
let stack_art = self.generate_stack_art(input);
let typed_ast = vec![
format!("Expression: {}", input),
String::new(),
"Types inferred successfully".to_string(),
];
let llvm_ir = analyze_expression(input)
.unwrap_or_else(|| vec!["(expression could not be compiled standalone)".to_string()]);
self.ir_content = IrContent {
stack_art,
typed_ast,
llvm_ir,
errors: vec![],
};
}
fn generate_stack_art(&self, input: &str) -> Vec<String> {
let words: Vec<&str> = input.split_whitespace().collect();
if words.is_empty() {
return vec![];
}
let mut lines = vec![format!("Expression: {}", input), String::new()];
for word in &words {
if let Some(effect) = self.get_word_effect(word) {
let before = Stack::with_rest("s");
let after = Stack::with_rest("s");
let transition = render_transition(&effect, &before, &after);
lines.extend(transition);
lines.push(String::new());
}
}
if lines.len() <= 2 {
lines.push("(no stack effects to display)".to_string());
}
lines
}
fn get_word_effect(&self, word: &str) -> Option<StackEffect> {
if word.parse::<i64>().is_ok() {
return Some(StackEffect::literal(word));
}
if word.parse::<f64>().is_ok() && word.contains('.') {
return Some(StackEffect::literal(word));
}
if word == "true" || word == "false" {
return Some(StackEffect::literal(word));
}
crate::ir::stack_effects::get_effect(word).cloned()
}
fn request_completions(&mut self) {
let input = &self.repl_state.input;
let cursor = self.repl_state.cursor;
if let Some(msg) = self.completions.request(input, cursor, &self.session_path) {
self.status_message = Some(msg);
} else if self.completions.items().is_empty() {
self.status_message = Some("No completions".to_string());
}
}
fn accept_completion(&mut self) {
let input = &self.repl_state.input;
let cursor = self.repl_state.cursor;
if let Some((word_start, completion)) = self.completions.accept(input, cursor) {
let before = &input[..word_start];
let after = &input[cursor..];
self.repl_state.input = format!("{}{}{}", before, completion, after);
self.repl_state.cursor = word_start + completion.len();
self.update_ir_preview();
}
}
pub fn render(&self, frame: &mut Frame) {
let area = frame.area();
let layout = ComputedLayout::compute(area, &self.layout_config, self.show_ir_pane);
let repl_pane = ReplPane::new(&self.repl_state).focused(true).prompt(
if self.editor.status() == "INSERT" {
"seq> "
} else {
"seq: "
},
);
frame.render_widget(&repl_pane, layout.repl);
if self.show_ir_pane && layout.ir_visible() {
let ir_pane = IrPane::new(&self.ir_content).mode(self.ir_mode);
frame.render_widget(&ir_pane, layout.ir);
}
self.render_status_bar(frame, layout.status);
if self.completions.is_visible() && !self.completions.items().is_empty() {
self.render_completions(frame, layout.repl);
}
}
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
let status = StatusContent::new()
.filename(&self.filename)
.mode(self.editor.status())
.ir_view(self.ir_mode.name());
let status_text = if let Some(msg) = &self.status_message {
msg.clone()
} else {
status.format(area.width)
};
let style = Style::default().bg(Color::DarkGray).fg(Color::White);
let paragraph = Paragraph::new(Line::from(Span::styled(status_text, style)));
frame.render_widget(paragraph, area);
}
fn render_completions(&self, frame: &mut Frame, repl_area: Rect) {
let items = self.completions.items();
let selected_index = self.completions.index();
let popup_height = (items.len() + 2) as u16; let popup_width = items.iter().map(|c| c.label.len()).max().unwrap_or(10) as u16 + 4;
let prompt_len = 5; let x = repl_area.x + prompt_len + self.repl_state.cursor as u16;
let x = x.min(repl_area.right().saturating_sub(popup_width));
let y = if repl_area.bottom() > popup_height + 1 {
repl_area.bottom() - popup_height - 1
} else {
repl_area.y
};
let popup_area = Rect::new(x, y, popup_width, popup_height);
frame.render_widget(Clear, popup_area);
let lines: Vec<Line> = items
.iter()
.enumerate()
.map(|(i, item)| {
let style = if i == selected_index {
Style::default()
.bg(Color::Blue)
.fg(Color::White)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
Line::from(Span::styled(format!(" {} ", item.label), style))
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan))
.style(Style::default().bg(Color::Black));
let paragraph = Paragraph::new(lines).block(block);
frame.render_widget(paragraph, popup_area);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_app_creation() -> Result<(), String> {
let app = App::new()?;
assert_eq!(app.editor.status(), "NORMAL");
assert!(!app.should_quit);
Ok(())
}
#[test]
fn test_mode_switching() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
assert_eq!(app.editor.status(), "INSERT");
app.handle_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.editor.status(), "NORMAL");
Ok(())
}
#[test]
fn test_insert_mode_typing() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
app.handle_key(KeyEvent::from(KeyCode::Char('h')));
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
assert_eq!(app.repl_state.input, "hi");
Ok(())
}
#[test]
fn test_normal_mode_navigation() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
for c in "hello".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
app.handle_key(KeyEvent::from(KeyCode::Esc));
app.handle_key(KeyEvent::from(KeyCode::Char('0')));
assert_eq!(app.repl_state.cursor, 0);
app.handle_key(KeyEvent::from(KeyCode::Char('l')));
assert_eq!(app.repl_state.cursor, 1);
app.handle_key(KeyEvent::from(KeyCode::Char('h')));
assert_eq!(app.repl_state.cursor, 0);
app.handle_key(KeyEvent::from(KeyCode::Char('$')));
assert_eq!(app.repl_state.cursor, 4); Ok(())
}
#[test]
fn test_history_navigation() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("first").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("second").with_output("2"));
app.handle_key(KeyEvent::from(KeyCode::Up));
assert_eq!(app.repl_state.input, "second");
app.handle_key(KeyEvent::from(KeyCode::Up));
assert_eq!(app.repl_state.input, "first");
app.handle_key(KeyEvent::from(KeyCode::Down));
assert_eq!(app.repl_state.input, "second");
Ok(())
}
#[test]
fn test_jk_history_navigation() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("first").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("second").with_output("2"));
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "second");
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "first");
app.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(app.repl_state.input, "second");
app.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(app.repl_state.input, "");
Ok(())
}
#[test]
fn test_jk_multiline_navigation() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("oldest").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("line1\nline2\nline3").with_output("2"));
app.handle_key(KeyEvent::from(KeyCode::Char('k'))); assert_eq!(app.repl_state.input, "line1\nline2\nline3");
app.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(app.repl_state.input, "");
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "line1\nline2\nline3");
app.handle_key(KeyEvent::from(KeyCode::Char('0')));
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "line1\nline2\nline3");
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "line1\nline2\nline3");
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "oldest");
Ok(())
}
#[test]
fn test_jk_with_unicode() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("hello 👋").with_output("1"));
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "hello 👋");
app.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(app.repl_state.input, "");
Ok(())
}
#[test]
fn test_jk_empty_input() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("history").with_output("1"));
assert_eq!(app.repl_state.input, "");
app.handle_key(KeyEvent::from(KeyCode::Char('k'))); assert_eq!(app.repl_state.input, "history");
app.handle_key(KeyEvent::from(KeyCode::Char('j'))); assert_eq!(app.repl_state.input, "");
Ok(())
}
#[test]
fn test_jk_insert_mode_no_history() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("history").with_output("1"));
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
assert_eq!(app.editor.status(), "INSERT");
app.handle_key(KeyEvent::from(KeyCode::Char('j')));
assert_eq!(app.repl_state.input, "j");
app.handle_key(KeyEvent::from(KeyCode::Char('k')));
assert_eq!(app.repl_state.input, "jk");
Ok(())
}
#[test]
fn test_quit_command() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL));
assert!(app.should_quit);
Ok(())
}
#[test]
fn test_repl_command() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
app.handle_key(KeyEvent::from(KeyCode::Char(':')));
app.handle_key(KeyEvent::from(KeyCode::Char('q')));
app.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(app.should_quit);
Ok(())
}
#[test]
fn test_word_effect_lookup() -> Result<(), String> {
let app = App::new()?;
assert!(app.get_word_effect("dup").is_some());
assert!(app.get_word_effect("swap").is_some());
assert!(app.get_word_effect("i.add").is_some());
assert!(app.get_word_effect("i.+").is_some());
assert!(app.get_word_effect("i.multiply").is_some());
assert!(app.get_word_effect("i.*").is_some());
assert!(app.get_word_effect("i.<").is_some());
assert!(app.get_word_effect("i.=").is_some());
assert!(app.get_word_effect("f.add").is_some());
assert!(app.get_word_effect("f.*").is_some());
assert!(app.get_word_effect("f.<").is_some());
assert!(app.get_word_effect("unknown").is_none());
Ok(())
}
#[test]
fn test_ctrl_c_quits() -> Result<(), String> {
let mut app = App::new()?;
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.handle_key(key);
assert!(app.should_quit);
Ok(())
}
#[test]
fn test_tab_completion() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
app.handle_key(KeyEvent::from(KeyCode::Char('d')));
app.handle_key(KeyEvent::from(KeyCode::Char('u')));
assert_eq!(app.repl_state.input, "du");
assert_eq!(app.repl_state.cursor, 2);
app.handle_key(KeyEvent::from(KeyCode::Tab));
assert!(
app.completions.is_visible(),
"Completions should be visible after Tab"
);
assert!(
!app.completions.items().is_empty(),
"Should have completions for 'du'"
);
assert!(
app.completions.items().iter().any(|c| c.label == "dup"),
"Should include 'dup' in completions"
);
Ok(())
}
#[test]
fn test_tab_completion_empty_prefix() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Tab));
assert!(
!app.completions.is_visible(),
"Should not show completions for empty prefix"
);
assert!(
app.status_message
.as_ref()
.is_some_and(|m| m.contains("type a prefix")),
"Should show 'type a prefix' message"
);
Ok(())
}
#[test]
fn test_search_mode_enter_and_exit() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("first entry").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("second entry").with_output("2"));
assert!(!app.search_mode);
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert!(app.search_mode);
assert!(
app.status_message
.as_ref()
.is_some_and(|m| m.starts_with("/")),
"Status should show search prompt"
);
app.handle_key(KeyEvent::from(KeyCode::Esc));
assert!(!app.search_mode);
Ok(())
}
#[test]
fn test_search_mode_filtering() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state.history.clear();
app.repl_state
.add_entry(HistoryEntry::new("dup swap").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("drop").with_output("2"));
app.repl_state
.add_entry(HistoryEntry::new("dup dup").with_output("3"));
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert!(app.search_mode);
app.handle_key(KeyEvent::from(KeyCode::Char('d')));
app.handle_key(KeyEvent::from(KeyCode::Char('u')));
app.handle_key(KeyEvent::from(KeyCode::Char('p')));
assert_eq!(app.search_matches.len(), 2);
assert_eq!(app.repl_state.input, "dup dup");
assert!(
app.status_message
.as_ref()
.is_some_and(|m| m.contains("/dup") && m.contains("(1/2)")),
"Status should show search pattern and match count"
);
Ok(())
}
#[test]
fn test_search_mode_accept() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("first").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("second").with_output("2"));
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
for c in "first".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
assert_eq!(app.repl_state.input, "first");
app.handle_key(KeyEvent::from(KeyCode::Enter));
assert!(!app.search_mode);
assert_eq!(app.repl_state.input, "first");
Ok(())
}
#[test]
fn test_search_mode_cancel_restores_input() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state
.add_entry(HistoryEntry::new("history entry").with_output("1"));
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
for c in "my input".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
app.handle_key(KeyEvent::from(KeyCode::Esc));
assert_eq!(app.repl_state.input, "my input");
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
for c in "history".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
assert_eq!(app.repl_state.input, "history entry");
app.handle_key(KeyEvent::from(KeyCode::Esc));
assert!(!app.search_mode);
assert_eq!(app.repl_state.input, "my input");
Ok(())
}
#[test]
fn test_search_mode_navigate_matches() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state.history.clear();
app.repl_state
.add_entry(HistoryEntry::new("test1").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("test2").with_output("2"));
app.repl_state
.add_entry(HistoryEntry::new("test3").with_output("3"));
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
for c in "test".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
assert_eq!(app.search_matches.len(), 3);
assert_eq!(app.search_match_index, 0);
assert_eq!(app.repl_state.input, "test3");
app.handle_key(KeyEvent::from(KeyCode::Tab));
assert_eq!(app.search_match_index, 1);
assert_eq!(app.repl_state.input, "test2");
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
assert_eq!(app.search_match_index, 0);
assert_eq!(app.repl_state.input, "test3");
app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT));
assert_eq!(app.search_match_index, 2);
assert_eq!(app.repl_state.input, "test1");
Ok(())
}
#[test]
fn test_search_mode_backspace() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state.history.clear();
app.repl_state
.add_entry(HistoryEntry::new("dup").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("drop").with_output("2"));
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
for c in "dup".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
assert_eq!(app.search_pattern, "dup");
assert_eq!(app.search_matches.len(), 1);
app.handle_key(KeyEvent::from(KeyCode::Backspace));
assert_eq!(app.search_pattern, "du");
assert_eq!(app.search_matches.len(), 1);
Ok(())
}
#[test]
fn test_search_mode_case_insensitive() -> Result<(), String> {
let mut app = App::new()?;
app.repl_state.history.clear();
app.repl_state
.add_entry(HistoryEntry::new("DUP").with_output("1"));
app.repl_state
.add_entry(HistoryEntry::new("Dup").with_output("2"));
app.repl_state
.add_entry(HistoryEntry::new("dup").with_output("3"));
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
for c in "dup".chars() {
app.handle_key(KeyEvent::from(KeyCode::Char(c)));
}
assert_eq!(app.search_matches.len(), 3);
Ok(())
}
#[test]
fn test_search_not_in_insert_mode() -> Result<(), String> {
let mut app = App::new()?;
app.handle_key(KeyEvent::from(KeyCode::Char('i')));
assert_eq!(app.editor.status(), "INSERT");
app.handle_key(KeyEvent::from(KeyCode::Char('/')));
assert!(!app.search_mode);
assert_eq!(app.repl_state.input, "/");
Ok(())
}
}