use crate::config::{paths, EditMode, HistoryConfig, TerminalConfig};
use anyhow::{Context, Result};
use crossterm::{execute, terminal};
use reedline::{
default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
ColumnarMenu, Emacs, FileBackedHistory, KeyCode, KeyModifiers, Keybindings, MenuBuilder,
Reedline, ReedlineEvent, ReedlineMenu, Signal, Vi,
};
use std::io;
use std::path::PathBuf;
use crate::repl::completer::OxurCompleter;
use crate::repl::oxur_prompt::OxurPrompt;
use crate::repl::pager;
use crate::repl::sexp_highlighter::SExpHighlighter;
use crate::repl::sexp_validator::SExpValidator;
fn format_version(version_str: &str) -> String {
let parts: Vec<&str> = version_str.split_whitespace().collect();
if parts.len() > 1 {
parts[1..].join(" ")
} else {
version_str.to_string()
}
}
fn visible_width(s: &str) -> usize {
let mut width = 0;
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next(); for esc_ch in chars.by_ref() {
if esc_ch == 'm' {
break;
}
}
}
} else {
width += 1;
}
}
width
}
fn substitute_placeholder_in_line(line: &str, placeholder: &str, value: &str) -> String {
if !line.contains(placeholder) {
return line.to_string();
}
let original_visible_width = visible_width(line);
let result = line.replace(placeholder, value);
let new_visible_width = visible_width(&result);
if new_visible_width == original_visible_width {
return result;
}
let border_pos = result
.rfind('\x1b')
.or_else(|| result.rfind('â•‘'))
.or_else(|| result.rfind('│'))
.or_else(|| result.rfind('|'))
.or_else(|| result.rfind(']'));
let (before_border, border_and_after) = if let Some(pos) = border_pos {
(&result[..pos], &result[pos..])
} else {
(&result[..], "")
};
if new_visible_width < original_visible_width {
let spaces_needed = original_visible_width - new_visible_width;
format!("{}{}{}", before_border, " ".repeat(spaces_needed), border_and_after)
} else {
let spaces_to_remove = new_visible_width - original_visible_width;
let trimmed = before_border.trim_end();
let trailing_space_count = before_border.len() - trimmed.len();
if trailing_space_count >= spaces_to_remove {
let keep_len = trimmed.len() + (trailing_space_count - spaces_to_remove);
format!("{}{}", &before_border[..keep_len], border_and_after)
} else {
format!("{}{}", trimmed, border_and_after)
}
}
}
fn substitute_banner_versions(
banner: &str,
metadata: &oxur_repl::metadata::SystemMetadata,
) -> String {
banner
.lines()
.map(|line| {
let line = substitute_placeholder_in_line(line, "N.N.N", &metadata.oxur_version);
let line = substitute_placeholder_in_line(
&line,
"M.M.M",
&format_version(&metadata.rust_version),
);
substitute_placeholder_in_line(&line, "L.L.L", &format_version(&metadata.cargo_version))
})
.collect::<Vec<_>>()
.join("\n")
}
fn add_completion_keybinding(keybindings: &mut Keybindings) {
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuNext,
]),
);
}
pub struct ReplTerminal {
editor: Reedline,
#[allow(dead_code)] history_path: PathBuf,
terminal_config: TerminalConfig,
}
impl ReplTerminal {
pub fn with_config(
terminal_config: TerminalConfig,
history_config: HistoryConfig,
) -> Result<Self> {
let edit_mode: Box<dyn reedline::EditMode> = match terminal_config.edit_mode {
EditMode::Emacs => {
let mut keybindings = default_emacs_keybindings();
add_completion_keybinding(&mut keybindings);
Box::new(Emacs::new(keybindings))
}
EditMode::Vi => {
let mut insert_keybindings = default_vi_insert_keybindings();
let mut normal_keybindings = default_vi_normal_keybindings();
add_completion_keybinding(&mut insert_keybindings);
add_completion_keybinding(&mut normal_keybindings);
Box::new(Vi::new(insert_keybindings, normal_keybindings))
}
};
let history_path = history_config.path.unwrap_or_else(paths::default_history_path);
let history_path_for_backend = if history_config.enabled {
history_path.clone()
} else {
std::env::temp_dir().join("oxur-repl-temp-history")
};
let history = Box::new(
FileBackedHistory::with_file(
history_config.max_size.unwrap_or(10000),
history_path_for_backend,
)
.context("Failed to create history backend")?,
);
let completion_menu = ColumnarMenu::default()
.with_name("completion_menu")
.with_columns(4)
.with_column_width(Some(20))
.with_column_padding(2);
let editor = Reedline::create()
.with_history(history)
.with_edit_mode(edit_mode)
.with_highlighter(Box::new(SExpHighlighter::new(terminal_config.color_enabled)))
.with_validator(Box::new(SExpValidator::new()))
.with_completer(Box::new(OxurCompleter::new()))
.with_menu(ReedlineMenu::EngineCompleter(Box::new(completion_menu)));
Ok(Self { editor, history_path, terminal_config })
}
pub fn read_line(&mut self, prompt: &str) -> Result<Option<String>> {
let oxur_prompt = OxurPrompt::new(
prompt.to_string(),
self.terminal_config.formatted_continuation_prompt(),
);
match self.editor.read_line(&oxur_prompt) {
Ok(Signal::Success(line)) => Ok(Some(line)),
Ok(Signal::CtrlC) => Ok(None),
Ok(Signal::CtrlD) => Err(anyhow::anyhow!("EOF")),
Err(e) => Err(anyhow::anyhow!("Input error: {}", e)),
}
}
pub fn read_line_default(&mut self) -> Result<Option<String>> {
let prompt = self.prompt();
self.read_line(&prompt)
}
pub fn prompt(&self) -> String {
self.terminal_config.formatted_prompt()
}
#[allow(dead_code)]
pub fn continuation_prompt(&self) -> String {
self.terminal_config.formatted_continuation_prompt()
}
pub fn save_history(&mut self) -> Result<()> {
Ok(())
}
#[allow(dead_code)]
pub fn color_enabled(&self) -> bool {
self.terminal_config.color_enabled
}
pub fn print_error(&self, msg: &str) {
if self.terminal_config.color_enabled {
eprintln!("\x1b[31mError:\x1b[0m {}", msg);
} else {
eprintln!("Error: {}", msg);
}
}
pub fn print_result(&self, value: &str) {
if self.terminal_config.color_enabled {
println!("\x1b[36m{}\x1b[0m", value);
} else {
println!("{}", value);
}
}
pub fn print_output(&self, output: &str) {
print!("{}", output);
}
pub fn print_help(&self, content: &str) {
if let Err(e) = pager::page_text(content) {
eprintln!("Warning: Pager failed ({}), printing directly", e);
println!("{}", content);
}
}
pub fn print_banner(&self, metadata: &oxur_repl::metadata::SystemMetadata) {
if let Some(ref banner) = self.terminal_config.banner {
let banner_with_versions = substitute_banner_versions(banner, metadata);
println!("{}", banner_with_versions);
} else {
if self.terminal_config.color_enabled {
println!(
"\x1b[1mOxur REPL\x1b[0m v{} | \x1b[90mRust: {} | Cargo: {}\x1b[0m",
metadata.oxur_version,
format_version(&metadata.rust_version),
format_version(&metadata.cargo_version)
);
} else {
println!(
"Oxur REPL v{} | Rust: {} | Cargo: {}",
metadata.oxur_version,
format_version(&metadata.rust_version),
format_version(&metadata.cargo_version)
);
}
println!("Type (help) for assistance, Ctrl-D to exit.");
}
println!();
}
pub fn print_goodbye(&self) {
println!();
if self.terminal_config.color_enabled {
println!("\x1b[33mGoodbye!\x1b[0m");
} else {
println!("Goodbye!");
}
}
pub fn clear_screen(&self) -> Result<()> {
execute!(io::stdout(), terminal::Clear(terminal::ClearType::All))?;
execute!(io::stdout(), crossterm::cursor::MoveTo(0, 0))?;
Ok(())
}
pub fn config(&self) -> &TerminalConfig {
&self.terminal_config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_history_path() {
let path = paths::default_history_path();
assert!(path.ends_with("repl_history"));
}
#[test]
fn test_terminal_config_prompt() {
let config = TerminalConfig::builder().prompt("test> ").color(false).build();
assert_eq!(config.formatted_prompt(), "test> ");
}
#[test]
#[serial_test::serial]
fn test_terminal_config_colored_prompt() {
colored::control::set_override(true);
let config = TerminalConfig::builder().prompt("test> ").color(true).build();
let test_prompt = config.formatted_prompt();
assert_ne!(test_prompt, "test> ");
assert!(test_prompt.contains("\x1b["));
assert!(test_prompt.contains("test> "));
let oxur_config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
let oxur_prompt = oxur_config.formatted_prompt();
assert_ne!(oxur_prompt, "oxur> ");
assert!(oxur_prompt.contains("\x1b["));
assert!(oxur_prompt.contains("o"));
assert!(oxur_prompt.contains("x"));
assert!(oxur_prompt.contains("u"));
assert!(oxur_prompt.contains("r"));
colored::control::unset_override();
}
#[test]
fn test_continuation_prompt() {
let config = TerminalConfig::builder().continuation_prompt("... ").color(false).build();
assert_eq!(config.formatted_continuation_prompt(), "... ");
}
#[test]
fn test_custom_banner() {
let config = TerminalConfig::builder().banner("Custom Welcome!").build();
assert_eq!(config.banner, Some("Custom Welcome!".to_string()));
}
#[test]
fn test_format_version_rustc() {
let version = "rustc 1.75.0 (82e1608df 2023-12-21)";
assert_eq!(format_version(version), "1.75.0 (82e1608df 2023-12-21)");
}
#[test]
fn test_format_version_cargo() {
let version = "cargo 1.75.0 (1d8b05cdd 2023-11-20)";
assert_eq!(format_version(version), "1.75.0 (1d8b05cdd 2023-11-20)");
}
#[test]
fn test_format_version_unknown() {
let version = "unknown";
assert_eq!(format_version(version), "unknown");
}
#[test]
fn test_visible_width_plain_text() {
assert_eq!(visible_width("Hello"), 5);
assert_eq!(visible_width(""), 0);
assert_eq!(visible_width("Test 123"), 8);
}
#[test]
fn test_visible_width_with_ansi_codes() {
assert_eq!(visible_width("\x1b[31mRed\x1b[0m"), 3); assert_eq!(visible_width("\x1b[1;32mGreen\x1b[0m"), 5);
assert_eq!(visible_width("\x1b[38;2;255;0;0mRed\x1b[0m"), 3); assert_eq!(visible_width("\x1b[38;2;138;59;13mâ•‘\x1b[0m"), 1); }
#[test]
fn test_visible_width_complex_banner_line() {
let line = "\x1b[38;2;138;59;13mâ•‘\x1b[0m text \x1b[38;2;138;59;13mâ•‘\x1b[0m";
assert_eq!(visible_width(line), 8);
}
#[test]
fn test_substitute_placeholder_in_line_no_placeholder() {
let line = "This is a test line";
let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
assert_eq!(result, line);
}
#[test]
fn test_substitute_placeholder_in_line_simple() {
let line = "Version: N.N.N â•‘";
let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
assert_eq!(visible_width(&result), visible_width(line));
assert!(result.contains("1.2.3"));
assert!(!result.contains("N.N.N"));
}
#[test]
fn test_substitute_placeholder_in_line_shorter_value() {
let line = "oxur: N.N.N â•‘";
let result = substitute_placeholder_in_line(line, "N.N.N", "1.0");
assert_eq!(visible_width(&result), visible_width(line));
assert!(result.contains("1.0"));
}
#[test]
fn test_substitute_placeholder_in_line_longer_value() {
let line = "oxur: N.N.N â•‘";
let result = substitute_placeholder_in_line(line, "N.N.N", "1.0.0-beta");
assert_eq!(visible_width(&result), visible_width(line));
assert!(result.contains("1.0.0-beta"));
}
#[test]
fn test_substitute_placeholder_in_line_with_ansi() {
let line = "\x1b[32moxur: N.N.N\x1b[37m \x1b[38;2;138;59;13mâ•‘\x1b[0m";
let result = substitute_placeholder_in_line(line, "N.N.N", "1.2.3");
assert_eq!(visible_width(&result), visible_width(line));
assert!(result.contains("1.2.3"));
assert!(!result.contains("N.N.N"));
}
#[test]
fn test_substitute_banner_versions() {
let banner = "oxur: N.N.N\nrustc: M.M.M\ncargo: L.L.L";
let metadata = oxur_repl::metadata::SystemMetadata {
oxur_version: "0.1.0".to_string(),
rust_version: "rustc 1.75.0 (82e1608df 2023-12-21)".to_string(),
cargo_version: "cargo 1.75.0 (1d8b05cdd 2023-11-20)".to_string(),
os_name: "Test".to_string(),
os_version: "1.0".to_string(),
arch: "x86_64".to_string(),
hostname: "test".to_string(),
pid: 1234,
cwd: std::path::PathBuf::from("/test"),
started_at: std::time::SystemTime::now(),
};
let result = substitute_banner_versions(banner, &metadata);
assert!(result.contains("oxur: 0.1.0"));
assert!(result.contains("rustc: 1.75.0 (82e1608df 2023-12-21)"));
assert!(result.contains("cargo: 1.75.0 (1d8b05cdd 2023-11-20)"));
assert!(!result.contains("N.N.N"));
assert!(!result.contains("M.M.M"));
assert!(!result.contains("L.L.L"));
}
#[test]
fn test_substitute_banner_versions_preserves_width() {
let banner = "â•‘ oxur: N.N.N â•‘\nâ•‘ rustc: M.M.M â•‘\nâ•‘ cargo: L.L.L â•‘";
let metadata = oxur_repl::metadata::SystemMetadata {
oxur_version: "0.2.0".to_string(),
rust_version: "rustc 1.76.0".to_string(),
cargo_version: "cargo 1.76.0".to_string(),
os_name: "Test".to_string(),
os_version: "1.0".to_string(),
arch: "x86_64".to_string(),
hostname: "test".to_string(),
pid: 1234,
cwd: std::path::PathBuf::from("/test"),
started_at: std::time::SystemTime::now(),
};
let result = substitute_banner_versions(banner, &metadata);
let original_lines: Vec<&str> = banner.lines().collect();
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(original_lines.len(), result_lines.len());
for (orig, res) in original_lines.iter().zip(result_lines.iter()) {
assert_eq!(
visible_width(orig),
visible_width(res),
"Line width mismatch:\nOriginal: {}\nResult: {}",
orig,
res
);
}
}
#[test]
fn test_substitute_banner_versions_with_real_banner() {
let config = crate::config::TerminalConfig::default();
let banner = config.banner.expect("Default banner should exist");
let metadata = oxur_repl::metadata::SystemMetadata {
oxur_version: "0.2.0".to_string(),
rust_version: "rustc 1.76.0 (07dca489a 2024-02-04)".to_string(),
cargo_version: "cargo 1.76.0 (c84b36747 2024-01-18)".to_string(),
os_name: "Test".to_string(),
os_version: "1.0".to_string(),
arch: "x86_64".to_string(),
hostname: "test".to_string(),
pid: 1234,
cwd: std::path::PathBuf::from("/test"),
started_at: std::time::SystemTime::now(),
};
let result = substitute_banner_versions(&banner, &metadata);
let original_lines: Vec<&str> = banner.lines().collect();
let result_lines: Vec<&str> = result.lines().collect();
assert_eq!(original_lines.len(), result_lines.len());
for (i, (orig, res)) in original_lines.iter().zip(result_lines.iter()).enumerate() {
let orig_width = visible_width(orig);
let res_width = visible_width(res);
assert_eq!(
orig_width,
res_width,
"Line {} width mismatch (orig={}, res={}):\nOriginal: {}\nResult: {}",
i + 1,
orig_width,
res_width,
orig,
res
);
}
assert!(result.contains("0.2.0"));
assert!(result.contains("1.76.0"));
assert!(!result.contains("N.N.N"));
assert!(!result.contains("M.M.M"));
assert!(!result.contains("L.L.L"));
}
#[test]
fn test_format_version_empty() {
let version = "";
assert_eq!(format_version(version), "");
}
#[test]
fn test_format_version_single_word() {
let version = "1.75.0";
assert_eq!(format_version(version), "1.75.0");
}
#[test]
fn test_format_version_many_parts() {
let version = "tool 1.0.0 extra info here";
assert_eq!(format_version(version), "1.0.0 extra info here");
}
#[test]
fn test_substitute_banner_no_placeholders() {
let banner = "Welcome to the REPL!";
let metadata = oxur_repl::metadata::SystemMetadata {
oxur_version: "0.1.0".to_string(),
rust_version: "rustc 1.75.0".to_string(),
cargo_version: "cargo 1.75.0".to_string(),
os_name: "Test".to_string(),
os_version: "1.0".to_string(),
arch: "x86_64".to_string(),
hostname: "test".to_string(),
pid: 1234,
cwd: std::path::PathBuf::from("/test"),
started_at: std::time::SystemTime::now(),
};
let result = substitute_banner_versions(banner, &metadata);
assert_eq!(result, "Welcome to the REPL!");
}
#[test]
fn test_substitute_banner_partial_placeholders() {
let banner = "Oxur N.N.N only";
let metadata = oxur_repl::metadata::SystemMetadata {
oxur_version: "0.2.0".to_string(),
rust_version: "rustc 1.76.0".to_string(),
cargo_version: "cargo 1.76.0".to_string(),
os_name: "Test".to_string(),
os_version: "1.0".to_string(),
arch: "x86_64".to_string(),
hostname: "test".to_string(),
pid: 1234,
cwd: std::path::PathBuf::from("/test"),
started_at: std::time::SystemTime::now(),
};
let result = substitute_banner_versions(banner, &metadata);
assert_eq!(result, "Oxur 0.2.0 only");
}
#[test]
fn test_add_completion_keybinding() {
let mut keybindings = default_emacs_keybindings();
add_completion_keybinding(&mut keybindings);
}
#[test]
fn test_add_completion_keybinding_vi_insert() {
let mut keybindings = default_vi_insert_keybindings();
add_completion_keybinding(&mut keybindings);
}
#[test]
fn test_add_completion_keybinding_vi_normal() {
let mut keybindings = default_vi_normal_keybindings();
add_completion_keybinding(&mut keybindings);
}
#[test]
fn test_terminal_config_default_banner() {
let config = TerminalConfig::default();
assert!(config.banner.is_some());
}
#[test]
fn test_terminal_config_color_disabled() {
let config = TerminalConfig::builder().color(false).build();
assert!(!config.color_enabled);
}
#[test]
fn test_terminal_config_color_enabled() {
let config = TerminalConfig::builder().color(true).build();
assert!(config.color_enabled);
}
#[test]
fn test_terminal_config_edit_mode_emacs() {
let config = TerminalConfig::builder().edit_mode(EditMode::Emacs).build();
assert!(matches!(config.edit_mode, EditMode::Emacs));
}
#[test]
fn test_terminal_config_edit_mode_vi() {
let config = TerminalConfig::builder().edit_mode(EditMode::Vi).build();
assert!(matches!(config.edit_mode, EditMode::Vi));
}
#[test]
fn test_history_config_default() {
let config = HistoryConfig::default();
assert!(config.enabled);
assert!(config.path.is_none());
assert_eq!(config.max_size, Some(10000));
}
#[test]
fn test_history_config_disabled() {
let config = HistoryConfig { enabled: false, path: None, max_size: None };
assert!(!config.enabled);
}
#[test]
fn test_history_config_custom_path() {
let path = PathBuf::from("/custom/history");
let config = HistoryConfig { enabled: true, path: Some(path.clone()), max_size: None };
assert_eq!(config.path, Some(path));
}
#[test]
fn test_history_config_custom_max_size() {
let config = HistoryConfig { enabled: true, path: None, max_size: Some(5000) };
assert_eq!(config.max_size, Some(5000));
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_with_config_emacs() {
let terminal_config =
TerminalConfig::builder().edit_mode(EditMode::Emacs).color(false).build();
let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
let result = ReplTerminal::with_config(terminal_config, history_config);
assert!(result.is_ok());
let terminal = result.unwrap();
assert!(!terminal.config().color_enabled);
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_with_config_vi() {
let terminal_config =
TerminalConfig::builder().edit_mode(EditMode::Vi).color(false).build();
let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
let result = ReplTerminal::with_config(terminal_config, history_config);
assert!(result.is_ok());
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_with_history_enabled() {
let terminal_config = TerminalConfig::builder().color(false).build();
let temp_dir = std::env::temp_dir();
let history_path = temp_dir.join("test-oxur-history");
let history_config =
HistoryConfig { enabled: true, path: Some(history_path.clone()), max_size: Some(500) };
let result = ReplTerminal::with_config(terminal_config, history_config);
assert!(result.is_ok());
let _ = std::fs::remove_file(history_path);
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_with_history_disabled() {
let terminal_config = TerminalConfig::builder().color(false).build();
let history_config = HistoryConfig { enabled: false, path: None, max_size: None };
let result = ReplTerminal::with_config(terminal_config, history_config);
assert!(result.is_ok());
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_config_accessor() {
let terminal_config = TerminalConfig::builder()
.prompt("test> ")
.continuation_prompt("..> ")
.color(false)
.build();
let history_config = HistoryConfig::default();
let terminal = ReplTerminal::with_config(terminal_config.clone(), history_config).unwrap();
let config = terminal.config();
assert_eq!(config.prompt, "test> ");
assert_eq!(config.continuation_prompt, "..> ");
assert!(!config.color_enabled);
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_prompt() {
let terminal_config = TerminalConfig::builder().prompt("custom> ").color(false).build();
let history_config = HistoryConfig::default();
let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
let prompt = terminal.prompt();
assert_eq!(prompt, "custom> ");
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_continuation_prompt() {
let terminal_config =
TerminalConfig::builder().continuation_prompt(">>> ").color(false).build();
let history_config = HistoryConfig::default();
let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
let cont_prompt = terminal.continuation_prompt();
assert_eq!(cont_prompt, ">>> ");
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_color_enabled() {
let terminal_config = TerminalConfig::builder().color(true).build();
let history_config = HistoryConfig::default();
let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
assert!(terminal.color_enabled());
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_color_disabled() {
let terminal_config = TerminalConfig::builder().color(false).build();
let history_config = HistoryConfig::default();
let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
assert!(!terminal.color_enabled());
}
#[test]
#[serial_test::serial]
fn test_repl_terminal_save_history() {
let terminal_config = TerminalConfig::builder().color(false).build();
let history_config = HistoryConfig::default();
let mut terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
let result = terminal.save_history();
assert!(result.is_ok());
}
}