#[cfg(target_os = "linux")]
use libedit::hint::Hint;
use libedit::suggestion::Suggestion;
#[cfg(target_os = "linux")]
use libedit::Completion;
use libedit::{EditLine, History, LineContext, Tokenizer};
use std::sync::Mutex;
static LIBEDIT_LOCK: Mutex<()> = Mutex::new(());
fn lock() -> std::sync::MutexGuard<'static, ()> {
LIBEDIT_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
#[test]
fn editline_init_and_drop() {
let _guard = lock();
let el = EditLine::new("test").unwrap();
drop(el);
}
#[cfg(target_os = "linux")]
fn open_fd_count() -> usize {
std::fs::read_dir("/proc/self/fd")
.map(|d| d.count())
.unwrap_or(0)
}
#[cfg(target_os = "linux")]
#[test]
fn editline_does_not_leak_fds() {
let _guard = lock();
drop(EditLine::new("test").unwrap());
let before = open_fd_count();
for _ in 0..64 {
let el = EditLine::new("test").unwrap();
drop(el);
}
let after = open_fd_count();
assert_eq!(
before, after,
"leaked file descriptors: {before} before vs {after} after 64 create/drop cycles"
);
}
#[test]
fn editline_set_and_get_editmode() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
el.set_edit_mode(true).unwrap();
assert!(el.edit_mode().unwrap());
}
#[test]
fn editline_set_int_via_consts() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
el.set_int(libedit::consts::EL_EDITMODE as i32, 1).unwrap();
let mode = el.get_int(libedit::consts::EL_EDITMODE as i32).unwrap();
assert_eq!(mode, 1);
}
#[test]
fn editline_attach_history() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
let mut h = History::new();
el.set_history(&mut h).unwrap();
}
#[test]
fn editline_set_editor_mode() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
el.set_editor(libedit::Editor::Vi).unwrap();
el.set_editor(libedit::Editor::Emacs).unwrap();
}
#[test]
fn editline_beep_is_a_noop_without_tty() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
el.beep();
}
#[test]
fn editline_register_candidate_styler() {
let _guard = lock();
use std::fmt::Write;
let mut el = EditLine::new("test").unwrap();
el.set_candidate_styler(|cand: &str, out: &mut String| {
let _ = write!(out, "[{cand}]");
});
}
#[test]
fn editline_register_suggester() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
el.set_suggester(|ctx: &LineContext| {
if ctx.line() == "he" {
Some(Suggestion::new("lp"))
} else {
None
}
})
.unwrap();
el.set_suggestion_style("\x1b[2m", "\x1b[0m");
}
#[test]
fn editline_bind_key_rejects_unknown_function() {
let _guard = lock();
let mut el = EditLine::new("test").unwrap();
assert!(el.bind_key("^L", "no-such-editor-function-xyz").is_err());
}
#[test]
fn editline_writer_write_and_flush() {
let _guard = lock();
use std::io::Write;
let mut el = EditLine::new("test").unwrap();
{
let mut out = el.output();
write!(out, "hello {}", 42).unwrap();
out.flush().unwrap();
}
{
let mut err = el.error_output();
writeln!(err, "diagnostic").unwrap();
err.flush().unwrap();
}
}
#[test]
fn history_init_and_drop() {
let _guard = lock();
let h = History::new();
assert!(h.is_empty());
drop(h);
}
#[test]
fn history_add_and_retrieve() {
let _guard = lock();
let mut h = History::new();
h.add("first command").unwrap();
assert_eq!(h.len(), 1);
assert!(!h.is_empty());
let first = h.first().unwrap();
assert_eq!(first, "first command");
}
#[test]
fn history_clear() {
let _guard = lock();
let mut h = History::new();
h.add("temp").unwrap();
assert_eq!(h.len(), 1);
h.clear();
assert!(h.is_empty());
assert_eq!(h.len(), 0);
assert!(h.first().is_err());
}
#[test]
fn history_empty_first_error() {
let _guard = lock();
let h = History::new();
assert!(h.first().is_err());
}
#[test]
fn history_with_size_eviction() {
let _guard = lock();
let mut h = History::with_size(2);
h.add("one").unwrap();
h.add("two").unwrap();
h.add("three").unwrap();
assert_eq!(h.len(), 2);
assert_eq!(h.first().unwrap(), "three");
}
#[test]
fn history_unique_suppresses_consecutive_duplicates() {
let _guard = lock();
let mut h = History::with_size(100);
h.set_unique(true);
h.add("same").unwrap();
h.add("same").unwrap();
h.add("same").unwrap();
assert_eq!(h.len(), 1);
assert_eq!(h.first().unwrap(), "same");
}
#[test]
fn history_unique_keeps_nonadjacent_duplicates() {
let _guard = lock();
let mut h = History::with_size(100);
h.set_unique(true);
h.add("a").unwrap();
h.add("b").unwrap();
h.add("a").unwrap();
assert_eq!(h.len(), 3);
assert_eq!(h.first().unwrap(), "a");
}
#[test]
fn history_without_unique_keeps_all_duplicates() {
let _guard = lock();
let mut h = History::with_size(100);
h.add("dup").unwrap();
h.add("dup").unwrap();
assert_eq!(h.len(), 2);
}
#[test]
fn history_save_and_load_roundtrip() {
let _guard = lock();
let mut path = std::env::temp_dir();
path.push(format!("libedit_hist_test_{}.txt", std::process::id()));
let _ = std::fs::remove_file(&path);
{
let mut h = History::with_size(100);
h.add("alpha").unwrap();
h.add("bravo").unwrap();
h.add("charlie").unwrap();
h.save(&path).expect("save");
}
{
let mut h = History::with_size(100);
assert!(h.is_empty());
h.load(&path).expect("load");
assert_eq!(h.len(), 3, "all entries should be restored");
assert_eq!(h.first().unwrap(), "charlie");
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn history_load_missing_file_errors() {
let _guard = lock();
let mut h = History::new();
let missing = std::path::Path::new("/nonexistent/dir/does_not_exist_hist");
assert!(h.load(missing).is_err());
}
#[test]
fn tokenizer_basic() {
let _guard = lock();
let mut t = Tokenizer::new(None).unwrap();
let words = t.tokenize("one two three").unwrap();
assert_eq!(words, vec!["one", "two", "three"]);
}
#[test]
fn tokenizer_empty() {
let _guard = lock();
let mut t = Tokenizer::new(None).unwrap();
let words = t.tokenize("").unwrap();
assert!(words.is_empty());
}
#[test]
fn tokenizer_reset_and_reuse() {
let _guard = lock();
let mut t = Tokenizer::new(None).unwrap();
let words = t.tokenize("a b").unwrap();
assert_eq!(words, vec!["a", "b"]);
t.reset();
let words = t.tokenize("x y z").unwrap();
assert_eq!(words, vec!["x", "y", "z"]);
}
#[cfg(target_os = "linux")]
#[test]
fn readline_prompt_does_not_segfault_on_tty() {
let _guard = lock();
let mut master: libc::c_int = 0;
let pid = unsafe {
libc::forkpty(
&mut master,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert!(pid >= 0, "forkpty failed");
if pid == 0 {
let mut el = EditLine::new("test").expect("init");
let _ = el.readline("prompt> ").expect("readline");
unsafe { libc::_exit(0) };
}
let line = b"hello\n";
unsafe {
libc::write(master, line.as_ptr() as *const libc::c_void, line.len());
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
unsafe { libc::close(master) };
assert_eq!(waited, pid, "waitpid failed");
let signalled = (status & 0x7f) != 0 && (status & 0x7f) != 0x7f;
let term_sig = status & 0x7f;
assert!(
!signalled,
"child terminated by signal {term_sig} (SIGSEGV is 11) -- prompt handling regressed"
);
}
#[cfg(target_os = "linux")]
fn run_in_pty(input: &[u8], body: impl FnOnce()) -> (i32, i32) {
let mut master: libc::c_int = 0;
let pid = unsafe {
libc::forkpty(
&mut master,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert!(pid >= 0, "forkpty failed");
if pid == 0 {
body();
unsafe { libc::_exit(0) };
}
let flags = unsafe { libc::fcntl(master, libc::F_GETFL, 0) };
unsafe { libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK) };
let mut buf = [0u8; 1024];
for &byte in input {
unsafe {
libc::write(master, [byte].as_ptr() as *const libc::c_void, 1);
}
let mut pfd = libc::pollfd {
fd: master,
events: libc::POLLIN,
revents: 0,
};
loop {
let ready = unsafe { libc::poll(&mut pfd, 1, 50) };
if ready <= 0 {
break;
}
let n = unsafe { libc::read(master, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n <= 0 {
break;
}
}
}
let mut pfd = libc::pollfd {
fd: master,
events: libc::POLLIN,
revents: 0,
};
loop {
let ready = unsafe { libc::poll(&mut pfd, 1, 100) };
if ready <= 0 {
break;
}
let n = unsafe { libc::read(master, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n <= 0 {
break;
}
}
let mut status: libc::c_int = 0;
let waited = unsafe { libc::waitpid(pid, &mut status, 0) };
unsafe { libc::close(master) };
assert_eq!(waited, pid, "waitpid failed");
let signal = status & 0x7f;
let code = (status >> 8) & 0xff;
(code, signal)
}
#[cfg(target_os = "linux")]
fn capture_pty(input: &[u8], body: impl FnOnce()) -> Vec<u8> {
let mut master: libc::c_int = 0;
let pid = unsafe {
libc::forkpty(
&mut master,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
assert!(pid >= 0, "forkpty failed");
if pid == 0 {
body();
unsafe { libc::_exit(0) };
}
let flags = unsafe { libc::fcntl(master, libc::F_GETFL, 0) };
unsafe { libc::fcntl(master, libc::F_SETFL, flags | libc::O_NONBLOCK) };
let mut captured = Vec::new();
let mut buf = [0u8; 1024];
for &byte in input {
unsafe {
libc::write(master, [byte].as_ptr() as *const libc::c_void, 1);
}
let mut pfd = libc::pollfd {
fd: master,
events: libc::POLLIN,
revents: 0,
};
loop {
let ready = unsafe { libc::poll(&mut pfd, 1, 50) };
if ready <= 0 {
break;
}
let n = unsafe { libc::read(master, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n <= 0 {
break;
}
captured.extend_from_slice(&buf[..n as usize]);
}
}
unsafe { libc::close(master) };
let mut status: libc::c_int = 0;
let _ = unsafe { libc::waitpid(pid, &mut status, 0) };
captured
}
#[cfg(target_os = "linux")]
fn visualize_bytes(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() + 16);
for &b in bytes {
match b {
0x1b => out.push_str("<ESC>"),
b'\n' => out.push_str("\\n"),
b'\r' => out.push_str("\\r"),
b'\t' => out.push_str("\\t"),
0x07 => out.push_str("<BEL>"),
0x08 => out.push_str("<BS>"),
0x20..=0x7e => out.push(b as char),
other => out.push_str(&format!("<\\x{other:02x}>")),
}
}
out
}
#[cfg(target_os = "linux")]
#[test]
fn suggestion_capture_stream() {
let _guard = lock();
let captured = capture_pty(b"history", || {
let mut el = EditLine::new("test").expect("init");
el.set_suggester(|ctx: &LineContext| {
let line = ctx.line();
let full = "history";
if !line.is_empty() && full.starts_with(line) && full.len() > line.len() {
Some(Suggestion::new(&full[line.len()..]))
} else {
None
}
})
.expect("set_suggester");
el.set_suggestion_style("\x1b[2m", "\x1b[0m");
let _ = el.readline("repl> ");
unsafe { libc::_exit(0) };
});
println!("---- captured terminal stream ----");
println!("{}", visualize_bytes(&captured));
println!("----------------------------------");
assert!(
captured.windows(4).any(|w| w == b"\x1b[2m"),
"expected a faint SGR (ghost text) in the captured stream"
);
}
#[cfg(target_os = "linux")]
#[test]
fn completion_inserts_unambiguous_match() {
let _guard = lock();
let (code, signal) = run_in_pty(b"se\t\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_completer(|ctx: &LineContext| {
let matches: Vec<String> = ["show", "set"]
.iter()
.filter(|c| c.starts_with(ctx.word()))
.map(|c| c.to_string())
.collect();
Completion::new(matches)
})
.expect("set_completer");
let line = el.readline("> ").expect("readline").unwrap_or_default();
if line.trim_start().starts_with("set") {
unsafe { libc::_exit(0) };
} else {
unsafe { libc::_exit(3) };
}
});
assert_eq!(signal, 0, "child died from signal {signal}");
assert_eq!(
code, 0,
"completed line did not start with `set` (code {code})"
);
}
#[cfg(target_os = "linux")]
#[test]
fn completion_lists_styled_candidates() {
let _guard = lock();
use std::fmt::Write;
let (code, signal) = run_in_pty(b"s\tet\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_completer(|ctx: &LineContext| {
let matches: Vec<String> = ["show", "set"]
.iter()
.filter(|c| c.starts_with(ctx.word()))
.map(|c| c.to_string())
.collect();
Completion::new(matches)
})
.expect("set_completer");
el.set_candidate_styler(|cand: &str, out: &mut String| {
let _ = write!(out, "\x1b[36m{cand}\x1b[0m");
});
let line = el.readline("> ").expect("readline").unwrap_or_default();
if line.trim() == "set" {
unsafe { libc::_exit(0) };
} else {
unsafe { libc::_exit(5) };
}
});
assert_eq!(signal, 0, "child died from signal {signal}");
assert_eq!(
code, 0,
"styled candidate listing corrupted the line (code {code})"
);
}
#[cfg(target_os = "linux")]
#[test]
fn completion_panic_is_contained() {
let _guard = lock();
let (_code, signal) = run_in_pty(b"x\t\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_completer(|_ctx: &LineContext| -> Completion {
panic!("boom inside completer");
})
.expect("set_completer");
let _ = el.readline("> ").expect("readline");
unsafe { libc::_exit(0) };
});
assert_eq!(
signal, 0,
"child terminated by signal {signal} -- panic escaped the FFI boundary"
);
}
#[cfg(target_os = "linux")]
#[test]
fn hinter_renders_without_crashing() {
let _guard = lock();
let (code, signal) = run_in_pty(b"se\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_hinter(|ctx: &LineContext| {
if ctx.word() == "se" {
Some(Hint::new("t -- set a value"))
} else {
None
}
});
let line = el.readline("> ").expect("readline").unwrap_or_default();
if line.trim() == "se" {
unsafe { libc::_exit(0) };
} else {
unsafe { libc::_exit(4) };
}
});
assert_eq!(signal, 0, "child died from signal {signal}");
assert_eq!(
code, 0,
"hint text leaked into the returned line (code {code})"
);
}
#[cfg(target_os = "linux")]
#[test]
fn hinter_panic_is_contained() {
let _guard = lock();
let (_code, signal) = run_in_pty(b"x\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_hinter(|_ctx: &LineContext| -> Option<Hint> {
panic!("boom inside hinter");
});
let _ = el.readline("> ").expect("readline");
unsafe { libc::_exit(0) };
});
assert_eq!(
signal, 0,
"child terminated by signal {signal} -- hinter panic escaped the FFI boundary"
);
}
#[cfg(target_os = "linux")]
#[test]
fn suggestion_accept_commits_suffix() {
let _guard = lock();
let (code, signal) = run_in_pty(b"he\x06\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_suggester(|ctx: &LineContext| {
if ctx.line() == "he" {
Some(Suggestion::new("lp"))
} else {
None
}
})
.expect("set_suggester");
el.set_suggestion_style("\x1b[2m", "\x1b[0m");
let line = el.readline("> ").expect("readline").unwrap_or_default();
if line.trim() == "help" {
unsafe { libc::_exit(0) };
} else {
unsafe { libc::_exit(6) };
}
});
assert_eq!(signal, 0, "child died from signal {signal}");
assert_eq!(
code, 0,
"suggestion was not committed on accept (code {code})"
);
}
#[cfg(target_os = "linux")]
#[test]
fn suggestion_not_in_line_without_accept() {
let _guard = lock();
let (code, signal) = run_in_pty(b"he\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_suggester(|ctx: &LineContext| {
if ctx.line() == "he" {
Some(Suggestion::new("lp"))
} else {
None
}
})
.expect("set_suggester");
let line = el.readline("> ").expect("readline").unwrap_or_default();
if line.trim() == "he" {
unsafe { libc::_exit(0) };
} else {
unsafe { libc::_exit(7) };
}
});
assert_eq!(signal, 0, "child died from signal {signal}");
assert_eq!(
code, 0,
"ghost text leaked into the submitted line (code {code})"
);
}
#[cfg(target_os = "linux")]
#[test]
fn suggestion_panic_is_contained() {
let _guard = lock();
let (_code, signal) = run_in_pty(b"x\n", || {
let mut el = EditLine::new("test").expect("init");
el.set_suggester(|_ctx: &LineContext| -> Option<Suggestion> {
panic!("boom inside suggester");
})
.expect("set_suggester");
let _ = el.readline("> ").expect("readline");
unsafe { libc::_exit(0) };
});
assert_eq!(
signal, 0,
"child terminated by signal {signal} -- suggester panic escaped the FFI boundary"
);
}